feat: add screen share for vision model & fix subtitle and ui bugs & update sdk version to 4.66.1

This commit is contained in:
quemingyi.wudong 2025-03-31 15:55:54 +08:00
parent 33f82e8b11
commit 4f088b0e89
28 changed files with 943 additions and 611 deletions

View File

@ -8,11 +8,12 @@
## 【必看】环境准备
- **Node 版本: 16.0+**
1. 需要准备两个 Terminal分别启动服务端、前端页面。
2. **根据你自定义的
2. 开通 ASR、TTS、LLM、RTC 等服务,可通过 [无代码跑通实时对话式](https://console.volcengine.com/rtc/guide) 快速开通服务, 点击 **快速开始** 中的 **跑通 Demo** 进行服务开通。
3. **根据你自定义的
RoomId、UserId 以及申请的 AppID、BusinessID(如有)、Token、ASR AppID、TTS AppID修改 `src/config/config.ts` 文件中 `ConfigFactory``BaseConfig` 的配置信息**。
3. 使用火山引擎控制台账号的 [AK、SK](https://console.volcengine.com/iam/keymanage?s=g)、[SessionToken](https://www.volcengine.com/docs/6348/1315561#sub?s=g)(临时token, 子账号才需要), 修改 `Server/app.js` 文件中的 `ACCOUNT_INFO`
4. 您需要在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g) 中创建接入点, 并将模型对应的接入点 ID 填入 `src/config/common.ts` 文件中的 `ARK_V3_MODEL_ID`, 否则无法正常启动智能体。
5. 如果您已经自行完成了服务端的逻辑,可以不依赖 Demo 中的 Server直接修改前端代码文件 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口,并在 `src/app/api.ts` 中修改接口的参数配置 `APIS_CONFIG`
4. 使用火山引擎控制台账号的 [AK、SK](https://console.volcengine.com/iam/keymanage?s=g)、[SessionToken](https://www.volcengine.com/docs/6348/1315561#sub?s=g)(临时token, 子账号才需要), 修改 `Server/app.js` 文件中的 `ACCOUNT_INFO`
5. 您需要在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g) 中创建接入点, 并将模型对应的接入点 ID 填入 `src/config/common.ts` 文件中的 `ARK_V3_MODEL_ID`, 否则无法正常启动智能体。
6. 如果您已经自行完成了服务端的逻辑,可以不依赖 Demo 中的 Server直接修改前端代码文件 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口,并在 `src/app/api.ts` 中修改接口的参数配置 `APIS_CONFIG`
## 快速开始
请注意,服务端和 Web 端都需要启动, 启动步骤如下:
@ -44,8 +45,8 @@ yarn dev
| :-- | :-- |
| **启动智能体之后, 对话无反馈,或者一直停留在 "AI 准备中, 请稍侯"** | <li>可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。</li><li>参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。</li><li>相关资源可能未开通或者用量不足,请再次确认。</li><li>**请检查当前使用的模型 ID 等内容都是正确且可用的。**</li> |
| `Server/app.js` 中的 `sessionToken` 是什么,该怎么填,为什么要填 | `sessionToken` 是火山引擎子账号发起 OpenAPI 请求时所必须携带的临时 Token获取方式可参考 [此文章末尾](https://www.volcengine.com/docs/6348/1315561?s=g)。 |
| **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 是否与项目中填写的一致。 |
| [StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.) 报错 | 由于目前设置的 RoomId、UserId 为固定值,重复调用 startAudioBot 会导致出错,只需先调用 stopAudioBot 后再重新 startAudioBot 即可。 |
| **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 是否与项目中填写的一致;或者 Token 可能过期, 可尝试重新生成下。 |
| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 由于目前设置的 RoomId、UserId 为固定值,重复调用 startAudioBot 会导致出错,只需先调用 stopAudioBot 后再重新 startAudioBot 即可。 |
| 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 |
| 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `Server/app.js` 中的 AK/SK/SessionToken 不正确 |
| 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 |
@ -57,3 +58,12 @@ yarn dev
- [场景介绍](https://www.volcengine.com/docs/6348/1310537?s=g)
- [Demo 体验](https://www.volcengine.com/docs/6348/1310559?s=g)
- [场景搭建方案](https://www.volcengine.com/docs/6348/1310560?s=g)
## 更新日志
### [1.5.0] - [2025-03-31]
- 修复部分 UI 问题
- 追加屏幕共享能力 (视觉模型可用,**读屏助手** 人设下可使用)
- 修改字幕逻辑,避免字幕回调中标点符号、大小写不一致引起的字幕重复问题
- 更新 RTC Web SDK 版本至 4.66.1
- 追加设备权限未授予时的提示

View File

@ -1,11 +1,11 @@
{
"name": "aigc",
"version": "1.4.0",
"version": "1.5.0",
"license": "BSD-3-Clause",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^1.8.3",
"@volcengine/rtc": "4.58.9",
"@volcengine/rtc": "4.66.1",
"@arco-design/web-react": "^2.65.0",
"autolinker": "^4.0.0",
"i18next": "^21.8.16",

View File

@ -63,6 +63,6 @@ export const resultHandler = (res: any) => {
const error = ResponseMetadata?.Error?.Message || Result;
Modal.error({
title: '接口调用错误',
content: `[${ResponseMetadata?.Action}]Failed(Reason: ${error})`,
content: `[${ResponseMetadata?.Action}]Failed(Reason: ${error}), 请参考 README 文档排查问题。`,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,5 @@
<svg class="icon" width="48" height="48" style="color: #A2AFC3; vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8403">
<rect y="1024" width="1024" height="1024" rx="4096" transform="rotate(-90 0 1024)" fill="#DEE3E9" fill-opacity="0.4" x="20"/>
<path d="M171.677538 898.993231a39.384615 39.384615 0 0 1-27.844923-67.229539L866.776615 97.122462a39.384615 39.384615 0 0 1 55.689847 55.689846L199.522462 887.453538a39.384615 39.384615 0 0 1-27.844924 11.539693z" fill="rgb(243, 247, 255)" p-id="8404" transform="scale(0.55) translate(410 460)"/>
<path d="M755.830154 906.043077H256.945231a35.446154 35.446154 0 0 1-20.716308-6.656l64.630154-64.630154h454.971077a35.643077 35.643077 0 0 1 0 71.286154z m-606.523077-142.454154a71.404308 71.404308 0 0 1-70.498462-71.207385V228.627692A71.325538 71.325538 0 0 1 150.055385 157.380923h605.144615L149.346462 763.155692z m713.491692 0H372.145231L930.107077 205.627077a70.892308 70.892308 0 0 1 3.938461 23.394461v463.281231a71.325538 71.325538 0 0 1-71.364923 71.010462z" fill="rgb(243, 247, 255)" p-id="8405" transform="scale(0.5) translate(530 530)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<svg class="icon" width="48" height="48" style="color: #666666; vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8403">
<rect y="1024" width="1024" height="1024" rx="4096" transform="rotate(-90 0 1024)" fill="#DEE3E9" fill-opacity="0.4" x="20"/>
<path d="M171.677538 898.993231a39.384615 39.384615 0 0 1-27.844923-67.229539L866.776615 97.122462a39.384615 39.384615 0 0 1 55.689847 55.689846L199.522462 887.453538a39.384615 39.384615 0 0 1-27.844924 11.539693z" fill="#EC3528" p-id="8404" transform="scale(0.55) translate(410 460)"/>
<path d="M755.830154 906.043077H256.945231a35.446154 35.446154 0 0 1-20.716308-6.656l64.630154-64.630154h454.971077a35.643077 35.643077 0 0 1 0 71.286154z m-606.523077-142.454154a71.404308 71.404308 0 0 1-70.498462-71.207385V228.627692A71.325538 71.325538 0 0 1 150.055385 157.380923h605.144615L149.346462 763.155692z m713.491692 0H372.145231L930.107077 205.627077a70.892308 70.892308 0 0 1 3.938461 23.394461v463.281231a71.325538 71.325538 0 0 1-71.364923 71.010462z" fill="#F53F3F" p-id="8405" transform="scale(0.5) translate(530 530)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,4 @@
<svg class="icon" width="48" height="48" style="color: #666666; vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11415">
<rect y="1024" width="1024" height="1024" rx="4096" transform="rotate(-90 0 1024)" fill="#DEE3E9" fill-opacity="0.4" x="20"/>
<path d="M191.872 728.224h640.288c58.176 0 88.384-29.248 88.384-88.736V269.216c0-59.456-30.208-88.704-88.384-88.704H191.808c-58.496 0-88.704 29.248-88.704 88.704v370.272c0 59.488 30.208 88.736 88.736 88.736z m320.448-94.112c-17.344 0-30.528-12.224-30.528-29.568V448l3.2-68.48-29.568 37.312-37.28 38.88a28.128 28.128 0 0 1-20.576 8.672c-16.064 0-27.648-11.552-27.648-27.328 0-8.32 2.24-14.432 8.032-20.544l110.272-111.872c7.68-7.712 14.784-11.232 24.096-11.232 9.632 0 16.704 3.84 24.096 11.232l110.272 111.872c5.76 6.08 8.32 12.192 8.32 20.576 0 15.744-11.84 27.296-27.936 27.296a28.16 28.16 0 0 1-20.576-8.64l-36.96-38.592-30.208-37.952 3.2 68.8v156.544c0 17.344-12.864 29.568-30.208 29.568z m163.936 206.272H347.424a29.088 29.088 0 1 1 0-58.176h328.832c16.064 0 29.248 13.184 29.248 29.248a29.28 29.28 0 0 1-29.248 28.928z" p-id="11416" transform="scale(0.55) translate(410 460)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -152,60 +152,6 @@
}
.button {
position: relative;
width: max-content !important;
height: 24px !important;
margin-top: 8px;
border-radius: 4px !important;
font-size: 12px !important;
background: linear-gradient(77.86deg, rgba(229, 242, 255, 0.5) -3.23%, rgba(217, 229, 255, 0.5) 51.11%, rgba(246, 226, 255, 0.5) 98.65%);
cursor: pointer;
.button-text {
background: linear-gradient(77.86deg, #3384FF -3.23%, #014BDE 51.11%, #A945FB 98.65%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 500;
line-height: 20px;
text-align: center;
}
}
.button::after {
content: '';
position: absolute;
border-radius: 3px;
top: 0px;
left: 0px;
width: 100%;
height: 22px;
background: white;
z-index: -1;
}
.button::before {
content: '';
position: absolute;
border-radius: 5px;
top: -2px;
left: -2px;
width: calc(100% + 4px);
height: 26px;
background: linear-gradient(90deg, rgba(0, 139, 255, 0.5) 0%, rgba(0, 98, 255, 0.5) 49.5%, rgba(207, 92, 255, 0.5) 100%);
z-index: -2;
}
.button:hover {
background: linear-gradient(77.86deg, rgba(200, 220, 255, 0.7) -3.23%, rgba(190, 210, 255, 0.7) 51.11%, rgba(230, 210, 255, 0.7) 98.65%);
}
.button:active {
background: linear-gradient(77.86deg, rgba(170, 190, 255, 0.9) -3.23%, rgba(160, 180, 255, 0.9) 51.11%, rgba(210, 180, 255, 0.9) 98.65%);
}
.footer {
width: calc(100% - 12px);
display: flex;

View File

@ -7,6 +7,7 @@ import { Button, Drawer, Input, Message } from '@arco-design/web-react';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconSwap } from '@arco-design/web-react/icon';
import { StreamIndex } from '@volcengine/rtc';
import CheckIcon from '../CheckIcon';
import Config, {
Icon,
@ -20,6 +21,7 @@ import Config, {
ModelSourceType,
VOICE_INFO_MAP,
VOICE_TYPE,
isVisionMode,
} from '@/config';
import TitleCard from '../TitleCard';
import CheckBoxSelector from '@/components/CheckBoxSelector';
@ -28,15 +30,22 @@ import { clearHistoryMsg, updateAIConfig, updateScene } from '@/store/slices/roo
import { RootState } from '@/store';
import utils from '@/utils/utils';
import { useDeviceState } from '@/lib/useCommon';
import VoiceTypeChangeSVG from '@/assets/img/VoiceTypeChange.svg';
import DoubaoModelSVG from '@/assets/img/DoubaoModel.svg';
import ModelChangeSVG from '@/assets/img/ModelChange.svg';
import styles from './index.module.less';
export interface IAISettingsProps {
open: boolean;
onOk?: () => void;
onCancel?: () => void;
}
const SCENES = [
SCENE.INTELLIGENT_ASSISTANT,
SCENE.SCREEN_READER,
SCENE.VIRTUAL_GIRL_FRIEND,
// SCENE.TEACHER,
SCENE.TRANSLATE,
SCENE.CHILDREN_ENCYCLOPEDIA,
SCENE.CUSTOMER_SERVICE,
@ -44,13 +53,13 @@ const SCENES = [
SCENE.CUSTOM,
];
function AISettings() {
function AISettings({ open, onCancel, onOk }: IAISettingsProps) {
const dispatch = useDispatch();
const { isVideoPublished, switchCamera } = useDeviceState();
const { isVideoPublished, isScreenPublished, switchScreenCapture, switchCamera } =
useDeviceState();
const room = useSelector((state: RootState) => state.room);
const [loading, setLoading] = useState(false);
const [use3Part, setUse3Part] = useState(false);
const [open, setOpen] = useState(false);
const [scene, setScene] = useState(room.scene);
const [data, setData] = useState({
prompt: Prompt[scene],
@ -63,10 +72,6 @@ function AISettings() {
customModelName: '',
});
const handleClick = () => {
setOpen(true);
};
const handleVoiceTypeChanged = (key: string) => {
setData((prev) => ({
...prev,
@ -116,18 +121,33 @@ function AISettings() {
Config.WelcomeSpeech = data.welcome;
dispatch(updateAIConfig(Config.aigcConfig));
if (isVisionMode(data.model)) {
switch (scene) {
case SCENE.SCREEN_READER:
/** 关摄像头,打开屏幕采集 */
room.isJoined && isVideoPublished && switchCamera();
Config.VisionSourceType = StreamIndex.STREAM_INDEX_SCREEN;
break;
default:
/** 关屏幕采集,打开摄像头 */
room.isJoined && !isVideoPublished && switchCamera();
room.isJoined && isScreenPublished && switchScreenCapture();
Config.VisionSourceType = StreamIndex.STREAM_INDEX_MAIN;
break;
}
} else {
/** 全关 */
room.isJoined && isVideoPublished && switchCamera();
room.isJoined && isScreenPublished && switchScreenCapture();
}
if (RtcClient.getAudioBotEnabled()) {
dispatch(clearHistoryMsg());
await RtcClient.updateAudioBot();
}
if (data.model === AI_MODEL.VISION) {
room.isJoined && !isVideoPublished && switchCamera(true);
} else {
room.isJoined && isVideoPublished && switchCamera(true);
}
setLoading(false);
setOpen(false);
onOk?.();
};
useEffect(() => {
@ -137,193 +157,193 @@ function AISettings() {
}, [open]);
return (
<>
<Button className={styles.button} onClick={handleClick}>
<div className={styles['button-text']}> AI </div>
</Button>
<Drawer
width={utils.isMobile() ? '100%' : 870}
closable={false}
maskClosable={false}
title={null}
className={styles.container}
style={{
padding: utils.isMobile() ? '0px' : '16px 8px',
}}
footer={
<div className={styles.footer}>
<div className={styles.suffix}>AI 退</div>
<Button loading={loading} className={styles.cancel} onClick={() => setOpen(false)}>
</Button>
<Button loading={loading} className={styles.confirm} onClick={handleUpdateConfig}>
</Button>
</div>
}
visible={open}
onCancel={() => setOpen(false)}
>
<div className={styles.title}>
<span className={styles['special-text']}> AI </span>
<Drawer
width={utils.isMobile() ? '100%' : 940}
closable={false}
maskClosable={false}
title={null}
className={styles.container}
style={{
padding: utils.isMobile() ? '0px' : '16px 8px',
}}
footer={
<div className={styles.footer}>
<div className={styles.suffix}>AI 退</div>
<Button loading={loading} className={styles.cancel} onClick={onCancel}>
</Button>
<Button loading={loading} className={styles.confirm} onClick={handleUpdateConfig}>
</Button>
</div>
<div className={styles['sub-title']}>
</div>
<div className={utils.isMobile() ? styles['scenes-mobile'] : styles.scenes}>
{SCENES.map((key) => (
}
visible={open}
onCancel={onCancel}
>
<div className={styles.title}>
<span className={styles['special-text']}> AI </span>
</div>
<div className={styles['sub-title']}>
</div>
<div className={utils.isMobile() ? styles['scenes-mobile'] : styles.scenes}>
{[...SCENES, null].map((key) =>
key ? (
<CheckIcon
key={key}
tag={key === SCENE.TEACHING_ASSISTANT ? '视觉理解模型' : ''}
tag={
[SCENE.TEACHING_ASSISTANT, SCENE.SCREEN_READER].includes(key) ? '视觉理解模型' : ''
}
icon={Icon[key as keyof typeof Icon]}
title={Name[key as keyof typeof Name]}
checked={key === scene}
blur={key !== scene && key === SCENE.CUSTOM}
onClick={() => handleChecked(key as SCENE)}
/>
))}
</div>
<div className={styles.configuration}>
{utils.isMobile() ? null : (
<div
className={styles.anchor}
style={{
/**
* @note 100px, 14px;
*/
left: `${SCENES.indexOf(scene) * 100 + 50 + SCENES.indexOf(scene) * 14}px`,
}}
/>
)}
<TitleCard title="Prompt">
<Input.TextArea
autoSize
value={data.prompt}
onChange={(val) => {
setData((prev) => ({
...prev,
prompt: val,
}));
}}
placeholder="请输入你需要的 Prompt 设定"
/>
</TitleCard>
<TitleCard title="欢迎语">
<Input.TextArea
autoSize
value={data.welcome}
onChange={(val) => {
setData((prev) => ({
...prev,
welcome: val,
}));
}}
placeholder="请输入欢迎语"
/>
</TitleCard>
) : utils.isMobile() ? (
<div style={{ width: '100px', height: '100px' }} />
) : null
)}
</div>
<div className={styles.configuration}>
{utils.isMobile() ? null : (
<div
className={styles['ai-settings']}
className={styles.anchor}
style={{
flexWrap: utils.isMobile() ? 'wrap' : 'nowrap',
/**
* @note 100px, 14px;
*/
left: `${SCENES.indexOf(scene) * 100 + 50 + SCENES.indexOf(scene) * 14}px`,
}}
>
<TitleCard title="音色">
<div className={styles['ai-settings-wrapper']}>
<CheckBoxSelector
label="音色选择"
data={Object.keys(VOICE_TYPE).map((type) => {
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="请选择你需要的音色"
/>
</div>
</TitleCard>
<div className={styles['ai-settings-model']}>
{use3Part ? (
<>
<TitleCard required title="第三方模型地址">
<Input.TextArea
autoSize
value={data.Url}
onChange={(val) => {
setData((prev) => ({
...prev,
Url: val,
}));
}}
placeholder="请输入第三方模型地址"
/>
</TitleCard>
<TitleCard title="请求密钥">
<Input.TextArea
autoSize
value={data.APIKey}
onChange={(val) => {
setData((prev) => ({
...prev,
APIKey: val,
}));
}}
placeholder="请输入请求密钥"
/>
</TitleCard>
<TitleCard title="模型名称">
<Input.TextArea
autoSize
value={data.customModelName}
onChange={(val) => {
setData((prev) => ({
...prev,
customModelName: val,
}));
}}
placeholder="请输入模型名称"
/>
</TitleCard>
</>
) : (
<TitleCard title="官方模型">
<CheckBoxSelector
label="模型选择"
data={Object.keys(AI_MODEL)
.map((type) => ({
key: AI_MODEL[type as keyof typeof AI_MODEL],
label: type.replaceAll('_', ' '),
icon: DoubaoModelSVG,
}))}
moreIcon={ModelChangeSVG}
moreText="更换模型"
placeHolder="请选择你需要的模型"
onChange={(key) => {
/>
)}
<TitleCard title="Prompt">
<Input.TextArea
autoSize
value={data.prompt}
onChange={(val) => {
setData((prev) => ({
...prev,
prompt: val,
}));
}}
placeholder="请输入你需要的 Prompt 设定"
/>
</TitleCard>
<TitleCard title="欢迎语">
<Input.TextArea
autoSize
value={data.welcome}
onChange={(val) => {
setData((prev) => ({
...prev,
welcome: val,
}));
}}
placeholder="请输入欢迎语"
/>
</TitleCard>
<div
className={styles['ai-settings']}
style={{
flexWrap: utils.isMobile() ? 'wrap' : 'nowrap',
}}
>
<TitleCard title="音色">
<div className={styles['ai-settings-wrapper']}>
<CheckBoxSelector
label="音色选择"
data={Object.keys(VOICE_TYPE).map((type) => {
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="请选择你需要的音色"
/>
</div>
</TitleCard>
<div className={styles['ai-settings-model']}>
{use3Part ? (
<>
<TitleCard required title="第三方模型地址">
<Input.TextArea
autoSize
value={data.Url}
onChange={(val) => {
setData((prev) => ({
...prev,
model: key as AI_MODEL,
Url: val,
}));
}}
value={data.model}
placeholder="请输入第三方模型地址"
/>
</TitleCard>
)}
<TitleCard title="请求密钥">
<Input.TextArea
autoSize
value={data.APIKey}
onChange={(val) => {
setData((prev) => ({
...prev,
APIKey: val,
}));
}}
placeholder="请输入请求密钥"
/>
</TitleCard>
<TitleCard title="模型名称">
<Input.TextArea
autoSize
value={data.customModelName}
onChange={(val) => {
setData((prev) => ({
...prev,
customModelName: val,
}));
}}
placeholder="请输入模型名称"
/>
</TitleCard>
</>
) : (
<TitleCard title="官方模型">
<CheckBoxSelector
label="模型选择"
data={Object.keys(AI_MODEL).map((type) => ({
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}
/>
</TitleCard>
)}
<Button size="mini" type="text" onClick={handleUseThirdPart}>
{use3Part ? '使用官方模型' : '使用第三方模型'} <IconSwap />
</Button>
</div>
<Button size="mini" type="text" onClick={handleUseThirdPart}>
{use3Part ? '使用官方模型' : '使用第三方模型'} <IconSwap />
</Button>
</div>
</div>
</Drawer>
</>
</div>
</Drawer>
);
}

View File

@ -137,4 +137,57 @@
background-color: #EAEDF1;
transform: rotate(-90deg);
}
}
.button {
position: relative;
width: max-content !important;
height: 24px !important;
margin-top: 8px;
border-radius: 4px !important;
font-size: 12px !important;
background: linear-gradient(77.86deg, rgba(229, 242, 255, 0.5) -3.23%, rgba(217, 229, 255, 0.5) 51.11%, rgba(246, 226, 255, 0.5) 98.65%);
cursor: pointer;
.button-text {
background: linear-gradient(77.86deg, #3384FF -3.23%, #014BDE 51.11%, #A945FB 98.65%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 500;
line-height: 20px;
text-align: center;
}
}
.button::after {
content: '';
position: absolute;
border-radius: 3px;
top: 0px;
left: 0px;
width: 100%;
height: 22px;
background: white;
z-index: -1;
}
.button::before {
content: '';
position: absolute;
border-radius: 5px;
top: -2px;
left: -2px;
width: calc(100% + 4px);
height: 26px;
background: linear-gradient(90deg, rgba(0, 139, 255, 0.5) 0%, rgba(0, 98, 255, 0.5) 49.5%, rgba(207, 92, 255, 0.5) 100%);
z-index: -2;
}
.button:hover {
background: linear-gradient(77.86deg, rgba(200, 220, 255, 0.7) -3.23%, rgba(190, 210, 255, 0.7) 51.11%, rgba(230, 210, 255, 0.7) 98.65%);
}
.button:active {
background: linear-gradient(77.86deg, rgba(170, 190, 255, 0.9) -3.23%, rgba(160, 180, 255, 0.9) 51.11%, rgba(210, 180, 255, 0.9) 98.65%);
}

View File

@ -4,6 +4,8 @@
*/
import { useSelector } from 'react-redux';
import { Button } from '@arco-design/web-react';
import { useState } from 'react';
import AISettings from '../AISettings';
import style from './index.module.less';
import DouBaoAvatar from '@/assets/img/DoubaoAvatarGIF.webp';
@ -14,16 +16,24 @@ interface IAvatarCardProps extends React.HTMLAttributes<HTMLDivElement> {
avatar?: string;
}
const ReversedVoiceType = Object.entries(VOICE_TYPE).reduce<Record<string, string>>((acc, [key, value]) => {
acc[value] = key;
return acc;
}, {});
const ReversedVoiceType = Object.entries(VOICE_TYPE).reduce<Record<string, string>>(
(acc, [key, value]) => {
acc[value] = key;
return acc;
},
{}
);
function AvatarCard(props: IAvatarCardProps) {
const room = useSelector((state: RootState) => state.room);
const [open, setOpen] = useState(false);
const scene = room.scene;
const { LLMConfig, TTSConfig } = room.aiConfig.Config || {};
const { avatar, className, ...rest } = props;
const voice = TTSConfig.ProviderParams.audio.voice_type;
const handleOpenDrawer = () => setOpen(true);
const handleCloseDrawer = () => setOpen(false);
return (
<div className={`${style.card} ${className}`} {...rest}>
@ -40,11 +50,12 @@ function AvatarCard(props: IAvatarCardProps) {
<div className={style['text-wrapper']}>
<div className={style['user-info']}>
<div className={style.title}>{Name[scene]}</div>
<div className={style.description}>
{ReversedVoiceType[TTSConfig?.VoiceType || '']}
</div>
<div className={style.description}> {ReversedVoiceType[voice || '']}</div>
<div className={style.description}> {LLMConfig.ModelName}</div>
<AISettings />
<AISettings open={open} onOk={handleCloseDrawer} onCancel={handleCloseDrawer} />
<Button className={style.button} onClick={handleOpenDrawer}>
<div className={style['button-text']}> AI </div>
</Button>
</div>
</div>
</div>

View File

@ -11,6 +11,7 @@ import TRANSLATE from '@/assets/img/TRANSLATE.png';
import CHILDREN_ENCYCLOPEDIA from '@/assets/img/CHILDREN_ENCYCLOPEDIA.png';
import TEACHING_ASSISTANT from '@/assets/img/TEACHING_ASSISTANT.png';
import CUSTOMER_SERVICE from '@/assets/img/CUSTOMER_SERVICE.png';
import SCREEN_READER from '@/assets/img/SCREEN_READER.png';
export enum ModelSourceType {
Custom = 'Custom',
@ -130,9 +131,12 @@ export enum SCENE {
CUSTOMER_SERVICE = 'CUSTOMER_SERVICE',
CHILDREN_ENCYCLOPEDIA = 'CHILDREN_ENCYCLOPEDIA',
TEACHING_ASSISTANT = 'TEACHING_ASSISTANT',
SCREEN_READER = 'SCREEN_READER',
CUSTOM = 'CUSTOM',
}
export const ScreenShareScene = [SCENE.SCREEN_READER];
export const Icon = {
[SCENE.INTELLIGENT_ASSISTANT]: INTELLIGENT_ASSISTANT,
[SCENE.VIRTUAL_GIRL_FRIEND]: VIRTUAL_GIRL_FRIEND,
@ -140,6 +144,7 @@ export const Icon = {
[SCENE.CHILDREN_ENCYCLOPEDIA]: CHILDREN_ENCYCLOPEDIA,
[SCENE.CUSTOMER_SERVICE]: CUSTOMER_SERVICE,
[SCENE.TEACHING_ASSISTANT]: TEACHING_ASSISTANT,
[SCENE.SCREEN_READER]: SCREEN_READER,
[SCENE.CUSTOM]: INTELLIGENT_ASSISTANT,
};
@ -150,6 +155,7 @@ export const Name = {
[SCENE.CHILDREN_ENCYCLOPEDIA]: '儿童百科',
[SCENE.CUSTOMER_SERVICE]: '售后客服',
[SCENE.TEACHING_ASSISTANT]: '课后助教',
[SCENE.SCREEN_READER]: '读屏助手',
[SCENE.CUSTOM]: '自定义',
};
@ -163,6 +169,7 @@ export const Welcome = {
[SCENE.CHILDREN_ENCYCLOPEDIA]: '你好小朋友,你的小脑袋里又有什么问题啦?',
[SCENE.CUSTOMER_SERVICE]: '感谢您在我们餐厅用餐,请问您有什么问题需要反馈吗?',
[SCENE.TEACHING_ASSISTANT]: '你碰到什么问题啦?让我来帮帮你。',
[SCENE.SCREEN_READER]: '欢迎使用读屏助手, 请开启屏幕采集,我会为你解说屏幕内容。',
[SCENE.CUSTOM]: '',
};
@ -173,6 +180,7 @@ export const Model = {
[SCENE.CHILDREN_ENCYCLOPEDIA]: AI_MODEL.DOUBAO_PRO_32K,
[SCENE.CUSTOMER_SERVICE]: AI_MODEL.DOUBAO_PRO_32K,
[SCENE.TEACHING_ASSISTANT]: AI_MODEL.VISION,
[SCENE.SCREEN_READER]: AI_MODEL.VISION,
[SCENE.CUSTOM]: AI_MODEL.DOUBAO_PRO_32K,
};
@ -183,6 +191,7 @@ export const Voice = {
[SCENE.CHILDREN_ENCYCLOPEDIA]: VOICE_TYPE.,
[SCENE.CUSTOMER_SERVICE]: VOICE_TYPE.,
[SCENE.TEACHING_ASSISTANT]: VOICE_TYPE.,
[SCENE.SCREEN_READER]: VOICE_TYPE.,
[SCENE.CUSTOM]: VOICE_TYPE.,
};
@ -213,6 +222,7 @@ export const Questions = {
'你们空调开得太冷了。',
],
[SCENE.TEACHING_ASSISTANT]: ['这个单词是什么意思?', '这道题该怎么做?', '我的表情是什么样的?'],
[SCENE.SCREEN_READER]: ['屏幕里这是什么?', '这道题你会做吗?', '帮我翻译解说下屏幕里的内容?'],
[SCENE.CUSTOM]: ['你能帮我解决什么问题?', '今天北京天气怎么样?', '你喜欢哪位流行歌手?'],
};
@ -299,5 +309,23 @@ export const Prompt = {
##
- 50
- `,
[SCENE.SCREEN_READER]: `##人设
AI +
##
1.
2.
3.
##
- 使
-
##
Markdown *#
`,
[SCENE.CUSTOM]: '',
};
export const isVisionMode = (model: AI_MODEL) => model.startsWith('Vision');

View File

@ -3,6 +3,7 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import { StreamIndex } from '@volcengine/rtc';
import {
TTS_CLUSTER,
ARK_V3_MODEL_ID,
@ -16,6 +17,7 @@ import {
AI_MODEL,
AI_MODE_MAP,
AI_MODEL_MODE,
isVisionMode,
} from '.';
export const CONVERSATION_SIGNATURE = 'conversation';
@ -37,6 +39,7 @@ export class ConfigFactory {
BusinessId: undefined,
/**
* @brief , ID, "Room123"
* @note 使
*/
RoomId: 'Room123',
/**
@ -59,7 +62,7 @@ export class ConfigFactory {
TTSAppId: 'Your TTS AppId',
/**
* @brief token
* 使
* 使
*/
TTSToken: undefined,
/**
@ -69,7 +72,7 @@ export class ConfigFactory {
ASRAppId: 'Your ASR AppId',
/**
* @brief AppId Access Token
* 使
* 使
*/
ASRToken: undefined,
};
@ -116,6 +119,11 @@ export class ConfigFactory {
*/
InterruptMode = true;
/**
* @brief 使/
*/
VisionSourceType = StreamIndex.STREAM_INDEX_MAIN;
get LLMConfig() {
const params: Record<string, unknown> = {
Mode: AI_MODE_MAP[this.Model || ''] || AI_MODEL_MODE.CUSTOM,
@ -134,16 +142,21 @@ export class ConfigFactory {
Url: this.Url,
Feature: JSON.stringify({ Http: true }),
};
if (this.Model === AI_MODEL.VISION) {
if (isVisionMode(this.Model)) {
params.VisionConfig = {
Enable: true,
SnapshotConfig: {
StreamType: this.VisionSourceType,
Height: 640,
ImagesLimit: 1,
},
};
}
return params;
}
get ASRConfig() {
return {
const params: Record<string, any> = {
Provider: 'volcano',
ProviderParams: {
/**
@ -152,7 +165,6 @@ export class ConfigFactory {
*/
Mode: 'smallmodel',
AppId: this.BaseConfig.ASRAppId,
...(this.BaseConfig.ASRToken ? { AccessToken: this.BaseConfig.ASRToken } : {}),
/**
* @note Cluster ID
* 具体链接为: https://console.volcengine.com/speech/service/16?s=g
@ -165,15 +177,18 @@ export class ConfigFactory {
},
VolumeGain: 0.3,
};
if (this.BaseConfig.ASRToken) {
params.ProviderParams.AccessToken = this.BaseConfig.ASRToken;
}
return params;
}
get TTSConfig() {
return {
const params: Record<string, any> = {
Provider: 'volcano',
ProviderParams: {
app: {
AppId: this.BaseConfig.TTSAppId,
...(this.BaseConfig.TTSToken ? { Token: this.BaseConfig.TTSToken } : {}),
Cluster: TTS_CLUSTER.TTS,
},
audio: {
@ -183,6 +198,10 @@ export class ConfigFactory {
},
IgnoreBracketText: [1, 2, 3, 4, 5],
};
if (this.BaseConfig.TTSToken) {
params.ProviderParams.app.Token = this.BaseConfig.TTSToken;
}
return params;
}
get aigcConfig() {

View File

@ -3,7 +3,6 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import VERTC, {
MirrorType,
StreamIndex,
@ -23,8 +22,10 @@ import VERTC, {
PlayerEvent,
NetworkQuality,
VideoRenderMode,
ScreenEncoderConfig,
} from '@volcengine/rtc';
import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr';
import { Message } from '@arco-design/web-react';
import openAPIs from '@/app/api';
import aigcConfig from '@/config';
import Utils from '@/utils/utils';
@ -34,6 +35,7 @@ export interface IEventListener {
handleError: (e: { errorCode: any }) => void;
handleUserJoin: (e: onUserJoinedEvent) => void;
handleUserLeave: (e: onUserLeaveEvent) => void;
handleTrackEnded: (e: { kind: string; isScreen: boolean }) => void;
handleUserPublishStream: (e: { userId: string; mediaType: MediaType }) => void;
handleUserUnpublishStream: (e: {
userId: string;
@ -45,7 +47,6 @@ export interface IEventListener {
handleLocalAudioPropertiesReport: (e: LocalAudioPropertiesInfo[]) => void;
handleRemoteAudioPropertiesReport: (e: RemoteAudioPropertiesInfo[]) => void;
handleAudioDeviceStateChanged: (e: DeviceInfo) => void;
handleUserMessageReceived: (e: { userId: string; message: any }) => void;
handleAutoPlayFail: (e: AutoPlayFailedEvent) => void;
handlePlayerEvent: (e: PlayerEvent) => void;
handleUserStartAudioCapture: (e: { userId: string }) => void;
@ -103,7 +104,9 @@ export class RTCClient {
await this.engine.registerExtension(AIAnsExtension);
AIAnsExtension.enable();
} catch (error) {
console.error((error as any).message);
console.warn(
`当前环境不支持 AI 降噪, 此错误可忽略, 不影响实际使用, e: ${(error as any).message}`
);
}
};
@ -111,6 +114,7 @@ export class RTCClient {
handleError,
handleUserJoin,
handleUserLeave,
handleTrackEnded,
handleUserPublishStream,
handleUserUnpublishStream,
handleRemoteStreamStats,
@ -118,7 +122,6 @@ export class RTCClient {
handleLocalAudioPropertiesReport,
handleRemoteAudioPropertiesReport,
handleAudioDeviceStateChanged,
handleUserMessageReceived,
handleAutoPlayFail,
handlePlayerEvent,
handleUserStartAudioCapture,
@ -129,6 +132,7 @@ export class RTCClient {
this.engine.on(VERTC.events.onError, handleError);
this.engine.on(VERTC.events.onUserJoined, handleUserJoin);
this.engine.on(VERTC.events.onUserLeave, handleUserLeave);
this.engine.on(VERTC.events.onTrackEnded, handleTrackEnded);
this.engine.on(VERTC.events.onUserPublishStream, handleUserPublishStream);
this.engine.on(VERTC.events.onUserUnpublishStream, handleUserUnpublishStream);
this.engine.on(VERTC.events.onRemoteStreamStats, handleRemoteStreamStats);
@ -136,7 +140,6 @@ export class RTCClient {
this.engine.on(VERTC.events.onAudioDeviceStateChanged, handleAudioDeviceStateChanged);
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, handleLocalAudioPropertiesReport);
this.engine.on(VERTC.events.onRemoteAudioPropertiesReport, handleRemoteAudioPropertiesReport);
this.engine.on(VERTC.events.onUserMessageReceived, handleUserMessageReceived);
this.engine.on(VERTC.events.onAutoplayFailed, handleAutoPlayFail);
this.engine.on(VERTC.events.onPlayerEvent, handlePlayerEvent);
this.engine.on(VERTC.events.onUserStartAudioCapture, handleUserStartAudioCapture);
@ -193,21 +196,42 @@ export class RTCClient {
audioOutputs: MediaDeviceInfo[];
videoInputs: MediaDeviceInfo[];
}> {
const { video, audio = true } = props || {};
const { video = false, audio = true } = props || {};
let audioInputs: MediaDeviceInfo[] = [];
let audioOutputs: MediaDeviceInfo[] = [];
let videoInputs: MediaDeviceInfo[] = [];
const { video: hasVideoPermission, audio: hasAudioPermission } = await VERTC.enableDevices({
video,
audio,
});
if (audio) {
const inputs = await VERTC.enumerateAudioCaptureDevices();
const outputs = await VERTC.enumerateAudioPlaybackDevices();
audioInputs = inputs.filter((i) => i.deviceId && i.kind === 'audioinput');
audioOutputs = outputs.filter((i) => i.deviceId && i.kind === 'audiooutput');
this._audioCaptureDevice = audioInputs.filter((i) => i.deviceId)?.[0]?.deviceId;
if (hasAudioPermission) {
if (!audioInputs?.length) {
Message.error('无麦克风设备, 请先确认设备情况。');
}
if (!audioOutputs?.length) {
Message.error('无扬声器设备, 请先确认设备情况。');
}
} else {
Message.error('暂无麦克风设备权限, 请先确认设备权限授予情况。');
}
}
if (video) {
videoInputs = await VERTC.enumerateVideoCaptureDevices();
videoInputs = videoInputs.filter((i) => i.deviceId && i.kind === 'videoinput');
this._videoCaptureDevice = videoInputs?.[0]?.deviceId;
if (hasVideoPermission) {
if (!videoInputs?.length) {
Message.error('无摄像头设备, 请先确认设备情况。');
}
} else {
Message.error('暂无摄像头设备权限, 请先确认设备权限授予情况。');
}
}
return {
@ -226,6 +250,16 @@ export class RTCClient {
await this.engine.stopVideoCapture();
};
startScreenCapture = async (enableAudio = false) => {
await this.engine.startScreenCapture({
enableAudio,
});
};
stopScreenCapture = async () => {
await this.engine.stopScreenCapture();
};
startAudioCapture = async (mic?: string) => {
await this.engine.startAudioCapture(mic || this._audioCaptureDevice);
};
@ -242,6 +276,18 @@ export class RTCClient {
this.engine.unpublishStream(mediaType);
};
publishScreenStream = async (mediaType: MediaType) => {
await this.engine.publishScreen(mediaType);
};
unpublishScreenStream = async (mediaType: MediaType) => {
await this.engine.unpublishScreen(mediaType);
};
setScreenEncoderConfig = async (description: ScreenEncoderConfig) => {
await this.engine.setScreenEncoderConfig(description);
};
/**
* @brief
* @param businessId
@ -286,12 +332,19 @@ export class RTCClient {
return this.engine.setLocalVideoMirrorType(type);
};
setLocalVideoPlayer = (userId: string, renderDom?: string | HTMLElement) => {
return this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
renderDom,
userId,
renderMode: VideoRenderMode.RENDER_MODE_HIDDEN,
});
setLocalVideoPlayer = (
userId: string,
renderDom?: string | HTMLElement,
isScreenShare = false
) => {
return this.engine.setLocalVideoPlayer(
isScreenShare ? StreamIndex.STREAM_INDEX_SCREEN : StreamIndex.STREAM_INDEX_MAIN,
{
renderDom,
userId,
renderMode: VideoRenderMode.RENDER_MODE_FILL,
}
);
};
/**
@ -344,11 +397,7 @@ export class RTCClient {
/**
* @brief AIGC
*/
commandAudioBot = (
command: COMMAND,
interruptMode = INTERRUPT_PRIORITY.NONE,
message = ''
) => {
commandAudioBot = (command: COMMAND, interruptMode = INTERRUPT_PRIORITY.NONE, message = '') => {
if (this.audioBotEnabled) {
this.engine.sendUserBinaryMessage(
aigcConfig.BotName,

View File

@ -30,14 +30,11 @@ import {
addAutoPlayFail,
removeAutoPlayFail,
updateAITalkState,
setHistoryMsg,
setCurrentMsg,
updateNetworkQuality,
} from '@/store/slices/room';
import RtcClient, { IEventListener } from './RtcClient';
import { setMicrophoneList, updateSelectedDevice } from '@/store/slices/device';
import Utils from '@/utils/utils';
import { useMessageHandler } from '@/utils/handler';
const useRtcListeners = (): IEventListener => {
@ -45,12 +42,19 @@ const useRtcListeners = (): IEventListener => {
const { parser } = useMessageHandler();
const playStatus = useRef<{ [key: string]: { audio: boolean; video: boolean } }>({});
const debounceSetHistoryMsg = Utils.debounce((text: string, user: string) => {
const isAudioEnable = RtcClient.getAudioBotEnabled();
if (isAudioEnable) {
dispatch(setHistoryMsg({ text, user }));
const handleTrackEnded = async (event: { kind: string; isScreen: boolean }) => {
const { kind, isScreen } = event;
/** 浏览器自带的屏幕共享关闭触发方式,通过 onTrackEnd 事件去关闭 */
if (isScreen && kind === 'video') {
await RtcClient.stopScreenCapture();
await RtcClient.unpublishScreenStream(MediaType.VIDEO);
dispatch(
updateLocalUser({
publishScreen: false,
})
);
}
}, 600);
};
const handleUserJoin = (e: onUserJoinedEvent) => {
const extraInfo = JSON.parse(e.userInfo.extraInfo || '{}');
@ -167,22 +171,6 @@ const useRtcListeners = (): IEventListener => {
}
};
const handleUserMessageReceived = (e: { userId: string; message: any }) => {
/** debounce 记录用户输入文字 */
if (e.message) {
const msgObj = JSON.parse(e.message || '{}');
if (msgObj.text) {
const { text: msg, definite, user_id: user } = msgObj;
if ((window as any)._debug_mode) {
dispatch(setHistoryMsg({ msg, user }));
} else {
debounceSetHistoryMsg(msg, user);
}
dispatch(setCurrentMsg({ msg, definite, user }));
}
}
};
const handleAutoPlayFail = (event: AutoPlayFailedEvent) => {
const { userId, kind } = event;
let playUser = playStatus.current?.[userId] || {};
@ -264,6 +252,7 @@ const useRtcListeners = (): IEventListener => {
handleError,
handleUserJoin,
handleUserLeave,
handleTrackEnded,
handleUserPublishStream,
handleUserUnpublishStream,
handleRemoteStreamStats,
@ -271,7 +260,6 @@ const useRtcListeners = (): IEventListener => {
handleLocalAudioPropertiesReport,
handleRemoteAudioPropertiesReport,
handleAudioDeviceStateChanged,
handleUserMessageReceived,
handleAutoPlayFail,
handlePlayerEvent,
handleUserStartAudioCapture,

View File

@ -27,7 +27,7 @@ import {
setDevicePermissions,
} from '@/store/slices/device';
import logger from '@/utils/logger';
import aigcConfig, { AI_MODEL } from '@/config';
import aigcConfig, { ScreenShareScene, isVisionMode } from '@/config';
export interface FormProps {
username: string;
@ -37,148 +37,9 @@ export interface FormProps {
export const useVisionMode = () => {
const room = useSelector((state: RootState) => state.room);
return [AI_MODEL.VISION].includes(room.aiConfig?.Config?.LLMConfig.ModelName);
};
export const useGetDevicePermission = () => {
const [permission, setPermission] = useState<{
audio: boolean;
}>();
const dispatch = useDispatch();
useEffect(() => {
(async () => {
const permission = await RtcClient.checkPermission();
dispatch(setDevicePermissions(permission));
setPermission(permission);
})();
}, [dispatch]);
return permission;
};
export const useJoin = (): [
boolean,
(formValues: FormProps, fromRefresh: boolean) => Promise<void | boolean>
] => {
const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions);
const room = useSelector((state: RootState) => state.room);
const dispatch = useDispatch();
const [joining, setJoining] = useState(false);
const listeners = useRtcListeners();
const handleAIGCModeStart = async () => {
if (room.isAIGCEnable) {
await RtcClient.stopAudioBot();
dispatch(clearCurrentMsg());
await RtcClient.startAudioBot();
} else {
await RtcClient.startAudioBot();
}
dispatch(updateAIGCState({ isAIGCEnable: true }));
};
async function disPatchJoin(formValues: FormProps): Promise<boolean | undefined> {
if (joining) {
return;
}
const isSupported = await VERTC.isSupported();
if (!isSupported) {
Modal.error({
title: '不支持 RTC',
content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。',
});
return;
}
setJoining(true);
const { username, roomId } = formValues;
const isVisionMode = aigcConfig.Model === AI_MODEL.VISION;
const token = aigcConfig.BaseConfig.Token;
/** 1. Create RTC Engine */
await RtcClient.createEngine({
appId: aigcConfig.BaseConfig.AppId,
roomId,
uid: username,
} as any);
/** 2.1 Set events callbacks */
RtcClient.addEventListeners(listeners);
/** 2.2 RTC starting to join room */
await RtcClient.joinRoom(token!, username);
console.log(' ------ userJoinRoom\n', `roomId: ${roomId}\n`, `uid: ${username}`);
/** 3. Set users' devices info */
const mediaDevices = await RtcClient.getDevices({
audio: true,
video: isVisionMode,
});
if (devicePermissions.audio) {
try {
await RtcClient.startAudioCapture();
// RtcClient.setAudioVolume(30);
} catch (e) {
logger.debug('No permission for mic');
}
}
if (devicePermissions.video && isVisionMode) {
try {
await RtcClient.startVideoCapture();
} catch (e) {
logger.debug('No permission for camera');
}
}
dispatch(
localJoinRoom({
roomId,
user: {
username,
userId: username,
publishAudio: true,
publishVideo: devicePermissions.video && isVisionMode,
},
})
);
dispatch(
updateSelectedDevice({
selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId,
selectedCamera: mediaDevices.videoInputs[0]?.deviceId,
})
);
dispatch(updateMediaInputs(mediaDevices));
setJoining(false);
Utils.setSessionInfo({
username,
roomId,
publishAudio: true,
});
handleAIGCModeStart();
}
return [joining, disPatchJoin];
};
export const useLeave = () => {
const dispatch = useDispatch();
return async function () {
dispatch(localLeaveRoom());
dispatch(updateAIGCState({ isAIGCEnable: false }));
await Promise.all([RtcClient.stopAudioCapture]);
RtcClient.leaveRoom();
dispatch(clearHistoryMsg());
dispatch(clearCurrentMsg());
return {
isVisionMode: isVisionMode(room.aiConfig?.Config?.LLMConfig.ModelName),
isScreenMode: ScreenShareScene.includes(room.scene),
};
};
@ -188,7 +49,7 @@ export const useDeviceState = () => {
const localUser = room.localUser;
const isAudioPublished = localUser.publishAudio;
const isVideoPublished = localUser.publishVideo;
const isScreenPublished = localUser.publishScreen;
const queryDevices = async (type: MediaType) => {
const mediaDevices = await RtcClient.getDevices({
audio: type === MediaType.AUDIO,
@ -220,40 +81,207 @@ export const useDeviceState = () => {
return mediaDevices;
};
const switchMic = (publish = true) => {
if (publish) {
!isAudioPublished
const switchMic = async (controlPublish = true) => {
if (controlPublish) {
await (!isAudioPublished
? RtcClient.publishStream(MediaType.AUDIO)
: RtcClient.unpublishStream(MediaType.AUDIO);
: RtcClient.unpublishStream(MediaType.AUDIO));
}
queryDevices(MediaType.AUDIO);
!isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture();
await (!isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture());
dispatch(
updateLocalUser({
publishAudio: !localUser.publishAudio,
publishAudio: !isAudioPublished,
})
);
};
const switchCamera = (publish = true) => {
if (publish) {
!isVideoPublished
const switchCamera = async (controlPublish = true) => {
if (controlPublish) {
await (!isVideoPublished
? RtcClient.publishStream(MediaType.VIDEO)
: RtcClient.unpublishStream(MediaType.VIDEO);
: RtcClient.unpublishStream(MediaType.VIDEO));
}
queryDevices(MediaType.VIDEO);
!localUser.publishVideo ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture();
await (!isVideoPublished ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture());
dispatch(
updateLocalUser({
publishVideo: !localUser.publishVideo,
publishVideo: !isVideoPublished,
})
);
};
const switchScreenCapture = async (controlPublish = true) => {
try {
if (controlPublish) {
await (!isScreenPublished
? RtcClient.publishScreenStream(MediaType.VIDEO)
: RtcClient.unpublishScreenStream(MediaType.VIDEO));
}
await (!isScreenPublished ? RtcClient.startScreenCapture() : RtcClient.stopScreenCapture());
dispatch(
updateLocalUser({
publishScreen: !isScreenPublished,
})
);
} catch {
console.warn('Not Authorized.');
}
};
return {
isAudioPublished,
isVideoPublished,
isScreenPublished,
switchMic,
switchCamera,
switchScreenCapture,
};
};
export const useGetDevicePermission = () => {
const [permission, setPermission] = useState<{
audio: boolean;
}>();
const dispatch = useDispatch();
useEffect(() => {
(async () => {
const permission = await RtcClient.checkPermission();
dispatch(setDevicePermissions(permission));
setPermission(permission);
})();
}, [dispatch]);
return permission;
};
export const useJoin = (): [
boolean,
(formValues: FormProps, fromRefresh: boolean) => Promise<void | boolean>
] => {
const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions);
const room = useSelector((state: RootState) => state.room);
const dispatch = useDispatch();
const { switchCamera, switchMic } = useDeviceState();
const [joining, setJoining] = useState(false);
const listeners = useRtcListeners();
const handleAIGCModeStart = async () => {
if (room.isAIGCEnable) {
await RtcClient.stopAudioBot();
dispatch(clearCurrentMsg());
await RtcClient.startAudioBot();
} else {
await RtcClient.startAudioBot();
}
dispatch(updateAIGCState({ isAIGCEnable: true }));
};
async function disPatchJoin(formValues: FormProps): Promise<boolean | undefined> {
if (joining) {
return;
}
const isSupported = await VERTC.isSupported();
if (!isSupported) {
Modal.error({
title: '不支持 RTC',
content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。',
});
return;
}
setJoining(true);
const { username, roomId } = formValues;
const isVision = isVisionMode(aigcConfig.Model);
const shouldGetVideoPermission = isVision && !ScreenShareScene.includes(room.scene);
const token = aigcConfig.BaseConfig.Token;
/** 1. Create RTC Engine */
const engineParams = {
appId: aigcConfig.BaseConfig.AppId,
roomId,
uid: username,
};
await RtcClient.createEngine(engineParams);
/** 2.1 Set events callbacks */
RtcClient.addEventListeners(listeners);
/** 2.2 RTC starting to join room */
await RtcClient.joinRoom(token!, username);
console.log(' ------ userJoinRoom\n', `roomId: ${roomId}\n`, `uid: ${username}`);
/** 3. Set users' devices info */
const mediaDevices = await RtcClient.getDevices({
audio: true,
video: shouldGetVideoPermission,
});
dispatch(
localJoinRoom({
roomId,
user: {
username,
userId: username,
},
})
);
dispatch(
updateSelectedDevice({
selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId,
selectedCamera: mediaDevices.videoInputs[0]?.deviceId,
})
);
dispatch(updateMediaInputs(mediaDevices));
setJoining(false);
if (devicePermissions.audio) {
try {
await switchMic();
// RtcClient.setAudioVolume(30);
} catch (e) {
logger.debug('No permission for mic');
}
}
if (devicePermissions.video && shouldGetVideoPermission) {
try {
await switchCamera();
} catch (e) {
logger.debug('No permission for camera');
}
}
Utils.setSessionInfo({
username,
roomId,
publishAudio: true,
});
handleAIGCModeStart();
}
return [joining, disPatchJoin];
};
export const useLeave = () => {
const dispatch = useDispatch();
return async function () {
await Promise.all([
RtcClient.stopAudioCapture,
RtcClient.stopScreenCapture,
RtcClient.stopVideoCapture,
]);
await RtcClient.leaveRoom();
dispatch(clearHistoryMsg());
dispatch(clearCurrentMsg());
dispatch(localLeaveRoom());
dispatch(updateAIGCState({ isAIGCEnable: false }));
};
};

View File

@ -3,68 +3,88 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useEffect } from 'react';
import { MediaType } from '@volcengine/rtc';
import { RootState } from '@/store';
import { useVisionMode } from '@/lib/useCommon';
import { useDeviceState, useVisionMode } from '@/lib/useCommon';
import RtcClient from '@/lib/RtcClient';
import { ScreenShareScene } from '@/config';
import styles from './index.module.less';
import CameraCloseNoteSVG from '@/assets/img/CameraCloseNote.svg';
import RtcClient from '@/lib/RtcClient';
import { updateLocalUser } from '@/store/slices/room';
import ScreenCloseNoteSVG from '@/assets/img/ScreenCloseNote.svg';
const LocalVideoID = 'local-video-player';
const LocalScreenID = 'local-screen-player';
function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const dispatch = useDispatch();
const room = useSelector((state: RootState) => state.room);
const isVisionMode = useVisionMode();
const localUser = room.localUser;
const isVideoPublished = localUser.publishVideo;
const { isVisionMode } = useVisionMode();
const isScreenMode = ScreenShareScene.includes(room.scene);
const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } =
useDeviceState();
const setVideoPlayer = () => {
if (isVisionMode && (isVideoPublished || isScreenPublished)) {
RtcClient.setLocalVideoPlayer(
room.localUser.username!,
isScreenMode ? LocalScreenID : LocalVideoID,
isScreenPublished
);
}
};
const handleOperateCamera = () => {
!localUser.publishVideo ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture();
switchCamera();
};
!localUser.publishVideo
? RtcClient.publishStream(MediaType.VIDEO)
: RtcClient.unpublishStream(MediaType.VIDEO);
dispatch(
updateLocalUser({
publishVideo: !localUser.publishVideo,
})
);
const handleOperateScreenShare = () => {
switchScreenCapture();
};
useEffect(() => {
if (isVisionMode && isVideoPublished) {
RtcClient.setLocalVideoPlayer(room.localUser.username!, LocalVideoID);
} else {
RtcClient.setLocalVideoPlayer(room.localUser.username!);
}
}, [isVisionMode, isVideoPublished]);
setVideoPlayer();
}, [isVideoPublished, isScreenPublished, isScreenMode]);
return isVisionMode ? (
<div className={`${styles['camera-wrapper']} ${className}`} {...rest}>
{isVideoPublished ? (
<div id={LocalVideoID} className={styles['camera-player']} />
) : (
<div className={styles['camera-placeholder']}>
<img
src={CameraCloseNoteSVG}
alt="close"
className={styles['camera-placeholder-close-note']}
/>
<div>
<div
id={LocalVideoID}
className={`${styles['camera-player']} ${
isVideoPublished && !isScreenMode ? '' : styles['camera-player-hidden']
}`}
/>
<div
id={LocalScreenID}
className={`${styles['camera-player']} ${
isScreenPublished && isScreenMode ? '' : styles['camera-player-hidden']
}`}
/>
<div
className={`${styles['camera-placeholder']} ${
isVideoPublished || isScreenPublished ? styles['camera-player-hidden'] : ''
}`}
>
<img
src={isScreenMode ? ScreenCloseNoteSVG : CameraCloseNoteSVG}
alt="close"
className={styles['camera-placeholder-close-note']}
/>
<div>
{isScreenMode ? (
<span onClick={handleOperateScreenShare} className={styles['camera-open-btn']}>
</span>
) : (
<span onClick={handleOperateCamera} className={styles['camera-open-btn']}>
</span>
</div>
<div></div>
)}
</div>
)}
<div></div>
</div>
</div>
) : null;
}

View File

@ -21,6 +21,14 @@ function Conversation(props: React.HTMLAttributes<HTMLDivElement>) {
const isAIReady = msgHistory.length > 0;
const containerRef = useRef<HTMLDivElement>(null);
const isUserTextLoading = (owner: string) => {
return owner === userId && isUserTalking;
};
const isAITextLoading = (owner: string) => {
return owner === Config.BotName && isAITalking;
};
useEffect(() => {
const container = containerRef.current;
if (container) {
@ -53,7 +61,9 @@ function Conversation(props: React.HTMLAttributes<HTMLDivElement>) {
<div className={styles.content}>
{value}
<div className={styles['loading-wrapper']}>
{isAIReady && (isUserTalking || isAITalking) && index === msgHistory.length - 1 ? (
{isAIReady &&
(isUserTextLoading(user) || isAITextLoading(user)) &&
index === msgHistory.length - 1 ? (
<Loading gap={3} className={styles.loading} dotClassName={styles.dot} />
) : (
''

View File

@ -4,11 +4,12 @@
*/
import { useSelector } from 'react-redux';
import { useState } from 'react';
import { memo, useState } from 'react';
import { Drawer } from '@arco-design/web-react';
import { useDeviceState, useLeave } from '@/lib/useCommon';
import { RootState } from '@/store';
import { AI_MODEL } from '@/config';
import { isVisionMode } from '@/config/common';
import { ScreenShareScene } from '@/config';
import utils from '@/utils/utils';
import Menu from '../../Menu';
@ -19,14 +20,25 @@ import MicOpenSVG from '@/assets/img/MicOpen.svg';
import SettingSVG from '@/assets/img/Setting.svg';
import MicCloseSVG from '@/assets/img/MicClose.svg';
import LeaveRoomSVG from '@/assets/img/LeaveRoom.svg';
import ScreenOnSVG from '@/assets/img/ScreenOn.svg';
import ScreenOffSVG from '@/assets/img/ScreenOff.svg';
function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const room = useSelector((state: RootState) => state.room);
const [open, setOpen] = useState(false);
const model = room.aiConfig.Config.LLMConfig?.ModelName;
const isScreenMode = ScreenShareScene.includes(room.scene);
const leaveRoom = useLeave();
const { isAudioPublished, isVideoPublished, switchMic, switchCamera } = useDeviceState();
const {
isAudioPublished,
isVideoPublished,
isScreenPublished,
switchMic,
switchCamera,
switchScreenCapture,
} = useDeviceState();
const handleSetting = () => {
setOpen(true);
};
@ -41,13 +53,22 @@ function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
className={style.btn}
alt="mic"
/>
{model === AI_MODEL.VISION ? (
<img
src={isVideoPublished ? CameraOpenSVG : CameraCloseSVG}
onClick={() => switchCamera(true)}
className={style.btn}
alt="camera"
/>
{isVisionMode(model) ? (
isScreenMode ? (
<img
src={isScreenPublished ? ScreenOnSVG : ScreenOffSVG}
onClick={() => switchScreenCapture()}
className={style.btn}
alt="screenShare"
/>
) : (
<img
src={isVideoPublished ? CameraOpenSVG : CameraCloseSVG}
onClick={() => switchCamera(true)}
className={style.btn}
alt="camera"
/>
)
) : (
''
)}
@ -60,6 +81,7 @@ function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
style={{
width: 'max-content',
}}
footer={null}
>
<Menu />
</Drawer>
@ -67,4 +89,4 @@ function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
</div>
);
}
export default ToolBar;
export default memo(ToolBar);

View File

@ -172,6 +172,15 @@
line-height: 22px;
}
.closed {
width: 100%;
text-align: center;
color: #737A87;
font-size: 14px;
font-weight: 400;
line-height: 19.6px;
}
.btns {
width: 100%;
display: flex;
@ -262,6 +271,10 @@
border-radius: 8px;
}
.camera-player-hidden {
display: none !important;
}
.camera-placeholder {
width: 100%;
display: flex;
@ -273,6 +286,8 @@
.camera-placeholder-close-note {
margin-bottom: 8px;
width: 60px;
height: 60px;
}
.camera-open-btn {

View File

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

View File

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

View File

@ -6,16 +6,18 @@
import { MediaType } from '@volcengine/rtc';
import DeviceDrawerButton from '../DeviceDrawerButton';
import { useVisionMode } from '@/lib/useCommon';
import AISettingAnchor from '../AISettingAnchor';
import Interrupt from '../Interrupt';
import styles from './index.module.less';
function Operation() {
const isVisionMode = useVisionMode();
const { isVisionMode, isScreenMode } = useVisionMode();
return (
<div className={`${styles.box} ${styles.device}`}>
<Interrupt />
<AISettingAnchor />
<DeviceDrawerButton />
{isVisionMode ? <DeviceDrawerButton type={MediaType.VIDEO} /> : ''}
{isVisionMode && !isScreenMode ? <DeviceDrawerButton type={MediaType.VIDEO} /> : ''}
</div>
);
}

View File

@ -4,6 +4,7 @@
*/
import VERTC from '@volcengine/rtc';
import { useEffect, useState } from 'react';
import { Tooltip, Typography } from '@arco-design/web-react';
import { useDispatch, useSelector } from 'react-redux';
import { useVisionMode } from '@/lib/useCommon';
@ -13,37 +14,39 @@ import Operation from './components/Operation';
import { Questions } from '@/config';
import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler';
import CameraArea from '../MainArea/Room/CameraArea';
import { setCurrentMsg, setHistoryMsg } from '@/store/slices/room';
import { setHistoryMsg, setInterruptMsg } from '@/store/slices/room';
import utils from '@/utils/utils';
import packageJson from '../../../../package.json';
import styles from './index.module.less';
function Menu() {
const dispatch = useDispatch();
const [question, setQuestion] = useState('');
const room = useSelector((state: RootState) => state.room);
const scene = room.scene;
const isJoined = room?.isJoined;
const isVisionMode = useVisionMode();
const handleQuestion = (question: string) => {
RtcClient.commandAudioBot(COMMAND.EXTERNAL_TEXT_TO_LLM, INTERRUPT_PRIORITY.HIGH, question);
dispatch(
setHistoryMsg({
text: question,
user: RtcClient.basicInfo.user_id,
paragraph: true,
definite: true,
})
);
dispatch(
setCurrentMsg({
text: question,
user: RtcClient.basicInfo.user_id,
paragraph: true,
definite: true,
})
);
const handleQuestion = (que: string) => {
RtcClient.commandAudioBot(COMMAND.EXTERNAL_TEXT_TO_LLM, INTERRUPT_PRIORITY.HIGH, que);
setQuestion(que);
};
useEffect(() => {
if (question && !room.isAITalking) {
dispatch(setInterruptMsg());
dispatch(
setHistoryMsg({
text: question,
user: RtcClient.basicInfo.user_id,
paragraph: true,
definite: true,
})
);
setQuestion('');
}
}, [question, room.isAITalking]);
return (
<div className={styles.wrapper}>
{isJoined && utils.isMobile() && isVisionMode ? (
@ -52,7 +55,7 @@ function Menu() {
</div>
) : null}
<div className={`${styles.box} ${styles.info}`}>
<div className={styles.bold}>Demo Version 1.4.0</div>
<div className={styles.bold}>Demo Version {packageJson.version}</div>
<div className={styles.bold}>SDK Version {VERTC.getSdkVersion()}</div>
{isJoined ? (
<div className={styles.gray}>

View File

@ -11,7 +11,6 @@ import {
RemoteAudioStats,
} from '@volcengine/rtc';
import config, { SCENE } from '@/config';
import utils from '@/utils/utils';
export interface IUser {
username?: string;
@ -33,6 +32,7 @@ export interface Msg {
time: string;
user: string;
paragraph?: boolean;
definite?: boolean;
isInterrupted?: boolean;
}
@ -59,6 +59,10 @@ export interface RoomState {
* @brief AI
*/
isAITalking: boolean;
/**
* @brief AI
*/
isAIThinking: boolean;
/**
* @brief
*/
@ -99,12 +103,14 @@ const initialState: RoomState = {
scene: SCENE.INTELLIGENT_ASSISTANT,
remoteUsers: [],
localUser: {
publishAudio: true,
publishVideo: true,
publishAudio: false,
publishVideo: false,
publishScreen: false,
},
autoPlayFailUser: [],
isJoined: false,
isAIGCEnable: false,
isAIThinking: false,
isAITalking: false,
isUserTalking: false,
networkQuality: NetworkQuality.UNKNOWN,
@ -131,15 +137,19 @@ export const roomSlice = createSlice({
}
) => {
state.roomId = payload.roomId;
state.localUser = payload.user;
state.localUser = {
...state.localUser,
...payload.user,
};
state.isJoined = true;
},
localLeaveRoom: (state) => {
state.roomId = undefined;
state.time = -1;
state.localUser = {
publishAudio: true,
publishVideo: true,
publishAudio: false,
publishVideo: false,
publishScreen: false,
};
state.remoteUsers = [];
state.isJoined = false;
@ -159,7 +169,7 @@ export const roomSlice = createSlice({
updateLocalUser: (state, { payload }: { payload: Partial<LocalUser> }) => {
state.localUser = {
...state.localUser,
...payload,
...(payload || {}),
};
},
@ -204,8 +214,14 @@ export const roomSlice = createSlice({
state.isAIGCEnable = payload.isAIGCEnable;
},
updateAITalkState: (state, { payload }) => {
state.isAIThinking = false;
state.isUserTalking = false;
state.isAITalking = payload.isAITalking;
},
updateAIThinkState: (state, { payload }) => {
state.isAIThinking = payload.isAIThinking;
state.isUserTalking = false;
},
updateAIConfig: (state, { payload }) => {
state.aiConfig = Object.assign(state.aiConfig, payload);
},
@ -213,36 +229,74 @@ export const roomSlice = createSlice({
state.msgHistory = [];
},
setHistoryMsg: (state, { payload }) => {
const paragraph = payload.paragraph;
const aiTalking = payload.user === config.BotName;
const userTalking = payload.user === state.localUser.userId;
if (paragraph) {
if (state.isAITalking) {
state.isAITalking = false;
}
if (state.isUserTalking) {
state.isUserTalking = false;
const { paragraph, definite } = payload;
/** 是否需要再创建新句子 */
const shouldCreateSentence = payload.definite;
state.isUserTalking = payload.user === state.localUser.userId;
if (state.msgHistory.length) {
const lastMsg = state.msgHistory.at(-1)!;
/** 当前讲话人更新字幕 */
if (lastMsg.user === payload.user) {
/** 如果上一句话是完整的 & 本次的话也是完整的, 则直接塞入 */
if (lastMsg.definite) {
state.msgHistory.push({
value: payload.text,
time: new Date().toString(),
user: payload.user,
definite,
paragraph,
});
} else {
/** 话未说完, 更新文字内容 */
lastMsg.value = payload.text;
lastMsg.time = new Date().toString();
lastMsg.paragraph = paragraph;
lastMsg.definite = definite;
lastMsg.user = payload.user;
}
/** 如果本次的话已经说完了, 提前塞入空字符串做准备 */
if (shouldCreateSentence) {
state.msgHistory.push({
value: '',
time: new Date().toString(),
user: '',
});
}
} else {
/** 换人说话了,塞入新句子 */
state.msgHistory.push({
value: payload.text,
time: new Date().toString(),
user: payload.user,
definite,
paragraph,
});
}
} else {
if (state.isAITalking !== aiTalking) {
state.isAITalking = aiTalking;
}
if (state.isUserTalking !== userTalking) {
state.isUserTalking = userTalking;
}
/** 首句话首字不会被打断 */
state.msgHistory.push({
value: payload.text,
time: new Date().toString(),
user: payload.user,
paragraph,
});
}
utils.addMsgWithoutDuplicate(state.msgHistory, {
user: payload.user,
value: payload.text,
time: new Date().toLocaleString(),
isInterrupted: false,
paragraph,
});
},
setInterruptMsg: (state) => {
const msg = state.msgHistory[state.msgHistory.length - 1];
msg.isInterrupted = true;
state.msgHistory[state.msgHistory.length - 1] = msg;
state.isAITalking = false;
if (!state.msgHistory.length) {
return;
}
/** 找到最后一个末尾的字幕, 将其状态置换为打断 */
for (let id = state.msgHistory.length - 1; id >= 0; id--) {
const msg = state.msgHistory[id];
if (msg.value) {
if (!msg.definite) {
state.msgHistory[id].isInterrupted = true;
}
break;
}
}
},
clearCurrentMsg: (state) => {
state.currentConversation = {};
@ -250,10 +304,6 @@ export const roomSlice = createSlice({
state.isAITalking = false;
state.isUserTalking = false;
},
setCurrentMsg: (state, { payload }) => {
const { user, ...info } = payload;
state.currentConversation[user || state.localUser.userId] = info;
},
},
});
@ -270,9 +320,9 @@ export const {
clearAutoPlayFail,
updateAIGCState,
updateAITalkState,
updateAIThinkState,
updateAIConfig,
setHistoryMsg,
setCurrentMsg,
clearHistoryMsg,
clearCurrentMsg,
setInterruptMsg,

View File

@ -6,10 +6,10 @@
import { useDispatch } from 'react-redux';
import logger from './logger';
import {
setCurrentMsg,
setHistoryMsg,
setInterruptMsg,
updateAITalkState,
updateAIThinkState,
} from '@/store/slices/room';
import RtcClient from '@/lib/RtcClient';
import Utils from '@/utils/utils';
@ -89,11 +89,16 @@ export const useMessageHandler = () => {
const { Code, Description } = Stage || {};
logger.debug(Code, Description);
switch (Code) {
case AGENT_BRIEF.THINKING:
dispatch(updateAIThinkState({ isAIThinking: true }));
break;
case AGENT_BRIEF.SPEAKING:
dispatch(updateAITalkState({ isAITalking: true }));
break;
case AGENT_BRIEF.FINISHED:
dispatch(updateAITalkState({ isAITalking: false }));
break;
case AGENT_BRIEF.INTERRUPTED:
dispatch(updateAITalkState({ isAITalking: false }));
dispatch(setInterruptMsg());
break;
default:
@ -118,7 +123,6 @@ export const useMessageHandler = () => {
dispatch(setHistoryMsg({ text: msg, user, paragraph, definite }));
}
}
dispatch(setCurrentMsg({ msg, definite, user, paragraph }));
}
},
/**
@ -141,8 +145,8 @@ export const useMessageHandler = () => {
ToolCallID: parsed?.tool_calls?.[0]?.id,
Content: map[name.toLocaleLowerCase().replaceAll('_', '')],
}),
'func',
),
'func'
)
);
},
};

View File

@ -3,12 +3,7 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import { Msg, RoomState } from '@/store/slices/room';
import RtcClient from '@/lib/RtcClient';
class Utils {
formatTime = (time: number): string => {
if (time < 0) {
return '00:00';
@ -46,9 +41,9 @@ class Utils {
const query = window.location.search.substring(1);
const pairs = query.split('&');
return pairs.reduce<{ [key: string]: string }>((queries, pair) => {
const [key, value] = decodeURIComponent(pair).split('=');
const [key, value] = pair.split('=');
if (key && value) {
queries[key] = value;
queries[key] = decodeURIComponent(value);
}
return queries;
}, {});
@ -58,34 +53,6 @@ class Utils {
isArray = Array.isArray;
debounce = (func: (...args: any[]) => void, wait: number) => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (...args: any[]) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
}, wait);
};
};
addMsgWithoutDuplicate = (arr: RoomState['msgHistory'], added: Msg) => {
if (arr.length) {
const last = arr.at(-1)!;
const { user, value, isInterrupted } = last;
if (
(added.user === RtcClient.basicInfo.user_id && last.user === added.user) ||
(user === added.user && added.value.startsWith(value) && value.trim())
) {
arr.pop();
added.isInterrupted = isInterrupted;
}
}
arr.push(added);
};
/**
* @brief TLV
*/
@ -119,7 +86,7 @@ class Utils {
* @note TLV
* | magic number | length(big-endian) | value |
* @param {ArrayBufferLike} tlvBuffer
* @returns
* @returns
*/
tlv2String(tlvBuffer: ArrayBufferLike) {
const typeBuffer = new Uint8Array(tlvBuffer, 0, 4);

View File

@ -2210,10 +2210,10 @@
"@typescript-eslint/types" "5.31.0"
eslint-visitor-keys "^3.3.0"
"@volcengine/rtc@4.58.9":
version "4.58.9"
resolved "https://registry.yarnpkg.com/@volcengine/rtc/-/rtc-4.58.9.tgz#841ebaddd5d4963c71abd33037bd76d1d490d928"
integrity sha512-nnXnNW9pVo8ynBSxVe0ikNIdxWfoSx5oOnwK7EoMCXdc2bJgHATpz/B+Kv2F1k4GjYAbo7ZcOm/g3cchvHgH5Q==
"@volcengine/rtc@4.66.1":
version "4.66.1"
resolved "https://registry.yarnpkg.com/@volcengine/rtc/-/rtc-4.66.1.tgz#1934c269b31216f43718ae46b169c59ac5e474f2"
integrity sha512-APznH6eosmKJC1HYJJ8s6G3Mq3OSgw6ivv6uCiayM5QNMBj+GW6zxf+MVsk5rm6r4R92TLwQErWonJ8yzGO4xA==
dependencies:
eventemitter3 "^4.0.7"