-
- {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}
+
+ {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();