简化项目结构

This commit is contained in:
lengbone 2026-04-02 09:40:23 +08:00
parent 275828ce1c
commit d468195db0
132 changed files with 2081 additions and 27234 deletions

View File

@ -1,63 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
RTC AIGC Demo — 基于火山引擎 RTC SDK 的实时 AI 语音对话演示应用,前后端分离架构。前端 React + TypeScript后端 Python FastAPI。
## 常用命令
### 前端 (frontend/)
```bash
cd frontend
npm install # 安装依赖
npm run dev # 开发服务器 (localhost:3000)
npm run build # 生产构建
npm run eslint # ESLint 检查并修复
npm run stylelint # LESS 样式检查
npm run prettier # 代码格式化
npm run test # 运行测试
```
### 后端 (backend/)
```bash
cd backend
cp .env.example .env # 首次需复制环境变量配置
uv sync # 安装依赖(使用 uv 包管理器)
uv run uvicorn server:app --host 0.0.0.0 --port 3001 --reload # 启动开发服务器
```
## 架构
### 前后端通信
- 前端默认连接 `http://localhost:3001`(配置在 `frontend/src/config/index.ts``AIGC_PROXY_HOST`
- 后端 FastAPI 入口:`backend/server.py`
### 前端核心模块
- **状态管理**: Redux Toolkit两个 slice`store/slices/room.ts`(房间状态)、`store/slices/device.ts`(设备状态)
- **RTC 封装**: `src/lib/RtcClient.ts` 封装 `@volcengine/rtc` SDK
- **API 层**: `src/app/api.ts` 定义 `getScenes`、`StartVoiceChat`、`StopVoiceChat` 接口
- **页面结构**: `pages/MainPage/` 包含 Room通话中和 Antechamber通话前两个主要区域
- **路径别名**: `@/``src/`(通过 craco + tsconfig paths 配置)
- **UI 组件库**: Arco Design
- **CSS**: LESS
### 后端核心模块
- **场景配置**: `config/custom_scene.py` — 从环境变量构建场景配置,自动生成 RoomId/UserId/Token
- **API 代理**: `/proxy` 端点转发请求到火山引擎 RTC OpenAPI含请求签名
- **LLM 集成**: `services/local_llm_service.py` — Ark SDK 对接SSE 流式响应
- **请求签名**: `security/signer.py`
- **Token 生成**: `security/rtc_token.py`
### LLM 模式
固定使用 `CustomLLM` 模式:火山 RTC 回调本后端的 `/api/chat_callback`,再由本后端调用方舟 LLM。
### 关键环境变量backend/.env
- `CUSTOM_ACCESS_KEY_ID` / `CUSTOM_SECRET_KEY`: 火山引擎凭证
- `CUSTOM_RTC_APP_ID` / `CUSTOM_RTC_APP_KEY`: RTC 应用配置
- `CUSTOM_LLM_URL`: 回调地址(默认本地,生产用 ngrok 公网地址)
- `LOCAL_LLM_API_KEY` / `LOCAL_LLM_MODEL`: 本地回调调用方舟的凭证
- `CUSTOM_ASR_APP_ID` / `CUSTOM_TTS_APP_ID`: 语音识别/合成配置
- `CUSTOM_AVATAR_*`: 数字人配置(可选)

136
README.md
View File

@ -1,136 +0,0 @@
# 交互式AIGC场景 AIGC Demo
此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。
跑通阶段时, 无须关心代码实现。当前推荐直接使用 `backend/.env` + `backend/config/custom_scene.py` 完成 `Custom` 场景配置。
## 简介
- 在 AIGC 对话场景下,火山引擎 AIGC-RTC Server 云端服务,通过整合 RTC 音视频流处理ASR 语音识别,大模型接口调用集成,以及 TTS 语音生成等能力提供基于流式语音的端到端AIGC能力链路。
- 用户只需调用基于标准的 OpenAPI 接口即可配置所需的 ASR、LLM、TTS 类型和参数。火山引擎云端计算服务负责边缘用户接入、云端资源调度、音视频流压缩、文本与语音转换处理以及数据订阅传输等环节。简化开发流程让开发者更专注在对大模型核心能力的训练及调试从而快速推进AIGC产品应用创新。
- 同时火山引擎 RTC拥有成熟的音频 3A 处理、视频处理等技术以及大规模音视频聊天能力,可支持 AIGC 产品更便捷的支持多模态交互、多人互动等场景能力,保持交互的自然性和高效性。
## 【必看】环境准备
> 本项目已重构为 monorepo 结构,前端位于 `frontend/`Python 后端位于 `backend/`
**前端环境Node 16.0+**
**后端环境Python 3.13+**
### 1. 运行环境
需要准备两个 Terminal分别启动后端服务和前端页面。
### 2. 服务开通
开通 ASR、TTS、LLM、RTC 等服务,可参考 [开通服务](https://www.volcengine.com/docs/6348/1315561?s=g) 进行相关服务的授权与开通。
### 3. 场景配置
当前真正生效的主配置入口是 `backend/.env` + `backend/config/custom_scene.py`
您可以自定义具体场景, 并按需根据模版填充 `SceneConfig`、`AccountConfig`、`RTCConfig`、`VoiceChat` 中需要的参数。
Demo 中以 `Custom` 场景为例,您也可以自行新增其他 JSON 场景。
`Custom` 场景建议先执行以下步骤:
```shell
cp backend/.env.example backend/.env
```
注意:
- `SceneConfig`:场景的信息,例如名称、头像等。
- `AccountConfig``Custom` 场景默认从 `backend/.env` 读取 AK/SK其他场景仍在 JSON 中配置。
- `RTCConfig`:场景下的 RTC 配置。
- AppId、AppKey 可从 https://console.volcengine.com/rtc/aigc/listRTC 中获取。
- `Custom` 场景的 AppId、AppKey、RoomId、UserId、Token 可通过 `backend/.env` 注入。
- RoomId、UserId 可自定义也可不填,交由服务端生成。
- `VoiceChat`: 场景下的 AIGC 配置。
- `Custom` 场景的 TaskId、Agent 用户信息、欢迎语、System Message 以及 LLM 模式参数均通过 `backend/.env` 注入。
- 固定使用 `CustomLLM` 模式,由本后端提供回调接口,推荐通过 ngrok 暴露公网地址。
- 可参考 https://www.volcengine.com/docs/6348/1558163 中参数描述,完整填写参数内容。
- `ASRConfig`、`TTSConfig`、`AvatarConfig` 等复杂结构由 `backend/config/custom_scene.py` 维护默认值,并从 `backend/.env` 读取关键运行参数。
- 当前首版默认不启用 RAG 主链路,`backend/services/rag_service.py` 仅保留为后续扩展位。
- 可通过 [快速跑通 Demo](https://console.volcengine.com/rtc/aigc/run?s=g) 快速获取参数,再分别填入 `backend/.env``backend/config/custom_scene.py` 的默认结构中。
### 第三方 CustomLLM 接入
当前 `backend` 自己提供回调接口,核心配置:
```dotenv
CUSTOM_LLM_URL=http://127.0.0.1:3001/api/chat_callback
CUSTOM_LLM_API_KEY=your-callback-token
LOCAL_LLM_API_KEY=your-ark-api-key
LOCAL_LLM_MODEL=your-ark-endpoint-id
```
本地起好 `backend` 以后,用 `ngrok` 暴露 `3001` 端口,再把 `CUSTOM_LLM_URL` 改成公网地址:
```dotenv
CUSTOM_LLM_URL=https://your-ngrok-domain.ngrok-free.app/api/chat_callback
```
说明:
- `CUSTOM_LLM_URL` 是写进 `StartVoiceChat.LLMConfig.Url` 的地址
- 默认可以先用本地地址启动服务,等 `ngrok` 跑起来后再改成公网 `https` 地址
- 当前 backend 内置的固定回调路由是 `POST /api/chat_callback`
- `RTC_OPENAPI_VERSION` 默认使用 `2025-06-01`
## 快速开始
请注意,服务端和 Web 端都需要启动, 启动步骤如下:
### 后端服务Python FastAPI
```shell
cd backend
cp .env.example .env
uv sync
uv run uvicorn server:app --host 0.0.0.0 --port 3001 --reload
```
### 前端页面
```shell
cd frontend
npm install
npm run dev
```
### 常见问题
| 问题 | 解决方案 |
| :-- | :-- |
| 如何使用第三方模型、Coze Bot | 当前主配置入口是 `backend/.env` + `backend/config/custom_scene.py`。如果接自己的模型,推荐使用当前 backend 内置的 `/api/chat_callback` 作为 `CustomLLM` 回调接口,再通过 `ngrok` 暴露公网地址,并把它填到 `CUSTOM_LLM_URL`。 |
| **启动智能体之后, 对话无反馈,或者一直停留在 "AI 准备中, 请稍侯";在启用数字人的情况下,一直停留在“数字人准备中,请稍候”** | <li>可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。</li><li>参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。</li><li>相关资源可能未开通或者用量不足/欠费,请再次确认。</li><li>**请检查当前使用的模型 ID / 数字人 AppId / Token 等内容都是正确且可用的。**</li><li>数字人服务有并发限制,当达到并发限制时,同样会表现为一直停留在“数字人准备中”状态</li> |
| **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 以及 Token 本身是否与项目中填写的一致;或者 Token 可能过期, 可尝试重新生成下。 |
| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 如果设置的 RoomId、UserId 为固定值,重复调用 startAgent 会导致出错,只需先调用 stopAgent 后再重新 startAgent 即可。 |
| 为什么麦克风、摄像头开启失败?浏览器报了`TypeError: Cannot read properties of undefined (reading 'getUserMedia')` | 检查当前页面是否为[安全上下文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts)(简单来说,检查当前页面是否为 `localhost` 或者 是否为 https 协议)。浏览器[限制](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) `getUserMedia` 只能在安全上下文中使用。 |
| 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 |
| 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `Custom` 场景请检查 `backend/.env` 中的 `CUSTOM_ACCESS_KEY_ID` / `CUSTOM_SECRET_KEY`;其他场景请检查对应 `backend/scenes/*.json` 中的 AK/SK |
| 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 |
| 不清楚什么是主账号,什么是子账号 | 可以参考[官方概念](https://www.volcengine.com/docs/6257/64963?hyperlink_open_type=lark.open_in_browser&s=g) 。|
| 我有自己的服务端了, 我应该怎么让前端调用我的服务端呢 | 修改 `frontend/src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名;如需同步调整接口路由,可再看 `frontend/src/app/api.ts` 里的 `BasicAPIs` / `AigcAPIs` |
如果有上述以外的问题,欢迎联系我们反馈。
### 相关文档
- [场景介绍](https://www.volcengine.com/docs/6348/1310537?s=g)
- [Demo 体验](https://www.volcengine.com/docs/6348/1310559?s=g)
- [场景搭建方案](https://www.volcengine.com/docs/6348/1310560?s=g)
## 更新日志
### OpenAPI 更新
参考 [OpenAPI 更新](https://www.volcengine.com/docs/6348/1544162) 中与 实时对话式 AI 相关的更新内容。
### Demo 更新
#### [1.6.0]
- 2025-09-30
- 更新数字人场景相关配置
- 2025-07-08
- 更新 RTC Web SDK 版本至 4.66.20
- 2025-06-26
- 修复进房有问题的 BUG
- 2025-06-23
- 简化 Demo 使用, 配置归一化。
- 删除无用组件。
- 追加服务端 README。
- 2025-06-18
- 更新 RTC Web SDK 版本至 4.66.16
- 更新 UI 和参数配置方式
- 更新 Readme 文档
- 追加 Node 服务的参数检测能力
- 追加 Node 服务的 Token 生成能力

View File

@ -16,19 +16,18 @@ CUSTOM_SCENE_ICON=https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAva
CUSTOM_TASK_ID=your-task-id
CUSTOM_AGENT_USER_ID=your-agent-user-id
CUSTOM_AGENT_TARGET_USER_ID= # 留空默认等于 RTC_USER_ID
CUSTOM_AGENT_WELCOME_MESSAGE=你好,我是小,有什么需要帮忙的吗?
CUSTOM_AGENT_WELCOME_MESSAGE=你好,我是小,有什么需要帮忙的吗?
CUSTOM_INTERRUPT_MODE=0
# ============ LLM 配置 (RTC OpenAPI 侧) ============
# RTC 会回调 CUSTOM_LLM_URL 指定的地址(通常是本后端的 /api/chat_callback
CUSTOM_LLM_SYSTEM_MESSAGE=你是小乖,性格幽默又善解人意。你在表达时需简明扼要,有自己的观点。
CUSTOM_LLM_THINKING_TYPE=disabled
CUSTOM_LLM_VISION_ENABLE=false
# 本地调试时,可先保持默认本地回调地址。
# 等 ngrok 跑起来后,再把 CUSTOM_LLM_URL 改成公网 https 地址,例如:
# https://your-ngrok-domain.ngrok-free.app/api/chat_callback
CUSTOM_LLM_URL=https://postvarioloid-leeann-didynamous.ngrok-free.dev
CUSTOM_LLM_URL= https://postvarioloid-leeann-didynamous.ngrok-free.dev
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token可留空
CUSTOM_LLM_API_KEY=
CUSTOM_LLM_MODEL_NAME=
@ -70,8 +69,8 @@ VOLC_KB_ENABLED=false
VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一)
VOLC_KB_RESOURCE_ID= # 知识库唯一 ID优先级高于 NAME
VOLC_KB_PROJECT=default # 知识库所属项目
VOLC_KB_ENDPOINT=https://api-knowledgebase.mlp.cn-beijing.volces.com
VOLC_KB_TOP_K=5 # 检索返回条数
VOLC_KB_ENDPOINT=https://postvarioloid-leeann-didynamous.ngrok-free.dev
VOLC_KB_TOP_K=3 # 检索返回条数
VOLC_KB_RERANK=false # 是否开启 rerank 重排
VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟)
@ -87,3 +86,8 @@ CUSTOM_AVATAR_BACKGROUND_URL=
CUSTOM_AVATAR_VIDEO_BITRATE=2000
CUSTOM_AVATAR_APP_ID=
CUSTOM_AVATAR_TOKEN=
# ============ Tools (Function Calling) 配置 ============
# 启用后 LLM 可主动调用已注册的工具函数查询真实数据
TOOLS_ENABLED=true
TOOLS_MAX_ROUNDS=5 # 单次对话最大工具调用轮数(防无限循环)

View File

@ -3,6 +3,7 @@ Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
SPDX-license-identifier: BSD-3-Clause
"""
import uuid
from typing import Any
from utils.env import (
@ -21,7 +22,7 @@ from utils.env import (
CUSTOM_SCENE_ID = "Custom"
DEFAULT_SCENE_NAME = "自定义助手"
DEFAULT_SCENE_NAME = "小块"
DEFAULT_SCENE_ICON = (
"https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png"
)
@ -32,7 +33,7 @@ DEFAULT_ASR_MODE = "smallmodel"
DEFAULT_ASR_CLUSTER = "volcengine_streaming_common"
DEFAULT_TTS_PROVIDER = "volcano"
DEFAULT_TTS_CLUSTER = "volcano_tts"
DEFAULT_TTS_VOICE_TYPE = "BV001_streaming"
DEFAULT_TTS_VOICE_TYPE = "zh_female_vv_uranus_bigtts"
DEFAULT_AVATAR_TYPE = "3min"
DEFAULT_AVATAR_ROLE = "250623-zhibo-linyunzhi"
DEFAULT_AVATAR_VIDEO_BITRATE = 2000
@ -43,12 +44,11 @@ def get_rtc_openapi_version() -> str:
def build_llm_settings_from_env(missing: list[str]) -> dict[str, Any]:
from config.prompt_config import load_prompt
settings = {
"system_message": require_env("CUSTOM_LLM_SYSTEM_MESSAGE", missing),
"system_message":load_prompt("default"),
"vision_enable": env_bool("CUSTOM_LLM_VISION_ENABLE", False),
"thinking_type": env_str(
"CUSTOM_LLM_THINKING_TYPE", DEFAULT_LLM_THINKING_TYPE
),
"thinking_type": env_str("CUSTOM_LLM_THINKING_TYPE", DEFAULT_LLM_THINKING_TYPE),
"url": require_env("CUSTOM_LLM_URL", missing),
"api_key": env_str("CUSTOM_LLM_API_KEY"),
"model_name": env_str("CUSTOM_LLM_MODEL_NAME"),
@ -106,7 +106,7 @@ def build_custom_scene_from_env() -> dict[str, Any]:
access_key_id = require_env("CUSTOM_ACCESS_KEY_ID", missing)
secret_key = require_env("CUSTOM_SECRET_KEY", missing)
rtc_app_id = require_env("CUSTOM_RTC_APP_ID", missing)
task_id = require_env("CUSTOM_TASK_ID", missing)
task_id = env_str("CUSTOM_TASK_ID") or str(uuid.uuid4())
agent_user_id = require_env("CUSTOM_AGENT_USER_ID", missing)
welcome_message = require_env("CUSTOM_AGENT_WELCOME_MESSAGE", missing)
asr_app_id = require_env("CUSTOM_ASR_APP_ID", missing)

View File

@ -0,0 +1,29 @@
"""
提示词注册表
- 每个提示词对应 prompts/ 目录下的一个 .md 文件
- 新增提示词1) prompts/ 下新建 .md 文件2) PROMPT_MAP 中追加一行
- 不同接口/模型调用 chat_stream(prompt_name="xxx") 来切换提示词
"""
from pathlib import Path
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
# name → .md 文件路径
PROMPT_MAP: dict[str, Path] = {
"default": PROMPTS_DIR / "default.md",
# 追加示例:
# "checkin": PROMPTS_DIR / "checkin.md",
}
def load_prompt(name: str = "default") -> str:
"""加载指定名称的提示词,文件不存在时抛出清晰的错误。"""
path = PROMPT_MAP.get(name)
if path is None:
available = list(PROMPT_MAP.keys())
raise ValueError(f"未知提示词名称: {name!r},可选: {available}")
if not path.exists():
raise FileNotFoundError(f"提示词文件不存在: {path}")
return path.read_text(encoding="utf-8").strip()

View File

@ -0,0 +1,20 @@
# 角色定位
你是语音助手小块,专门负责解答考勤打卡相关问题和系统使用说明,回答会直接朗读给用户。
# 输出规范
- 禁止使用任何Markdown语法比如加粗、标题、表格、列表符号等
- 数字要口语化,像“二十三个人”“出勤率百分之九十五”这样说
- 每次回答控制在三到五句话,语气要自然,就像真人说话一样
# 信息使用优先级
回答问题时按以下顺序使用信息:
1. 优先用系统提供的知识库内容,回答系统功能介绍、操作说明类问题时,必须以知识库为准,不能自己发挥
2. 回答考勤数据、打卡记录等实时数据时,必须调用工具查询,不能凭空编造数字
# 工具使用规范
- 用户问某部门出勤、打卡人数这类数据时,要调用对应的工具
- 工具返回结果后,用口语总结关键信息,不要直接读原始数据格式
# 兜底处理
- 如果知识库和工具都查不到相关信息,就告诉用户“暂时查不到,稍后再试”
- 遇到超出考勤和系统说明范围的问题,简短回应“这个我帮不上忙”,不用展开说别的

View File

@ -7,8 +7,8 @@ import json
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from schemas.chat import ChatCallbackRequest
from services.local_llm_service import local_llm_service
from services.rag_service import rag_service
from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings
from utils.responses import custom_llm_error_response
@ -16,23 +16,16 @@ router = APIRouter()
@router.post("/api/chat_callback")
async def chat_callback(request: Request):
async def chat_callback(request: Request, body: ChatCallbackRequest):
try:
settings = get_custom_llm_callback_settings()
ensure_custom_llm_authorized(request, settings["api_key"])
payload = await request.json()
except PermissionError as exc:
return custom_llm_error_response(
str(exc),
code="AuthenticationError",
status_code=401,
)
except json.JSONDecodeError:
return custom_llm_error_response(
"请求体必须是合法的 JSON",
code="BadRequest",
status_code=400,
)
except ValueError as exc:
return custom_llm_error_response(str(exc))
except Exception as exc:
@ -42,8 +35,8 @@ async def chat_callback(request: Request):
status_code=500,
)
messages = payload.get("messages")
if not isinstance(messages, list) or not messages:
messages = [m.model_dump() for m in body.messages]
if not messages:
return custom_llm_error_response(
"messages 不能为空",
code="BadRequest",
@ -58,24 +51,15 @@ async def chat_callback(request: Request):
status_code=400,
)
import time as _time
user_query = last_message.get("content", "")
_rag_t0 = _time.monotonic()
try:
rag_context = await rag_service.retrieve(user_query)
except Exception as exc:
print(f"⚠️ RAG 检索异常,已跳过: {exc}")
rag_context = ""
print(f"⏱️ RAG 检索耗时: {_time.monotonic() - _rag_t0:.2f}s")
# RAG 已改为 tool 按需调用,不再预检索
try:
stream_iterator = local_llm_service.chat_stream(
history_messages=messages,
rag_context=rag_context,
request_options={
"temperature": payload.get("temperature"),
"max_tokens": payload.get("max_tokens"),
"top_p": payload.get("top_p"),
"temperature": body.temperature,
"max_tokens": body.max_tokens,
"top_p": body.top_p,
},
)
except Exception as exc:

View File

@ -9,6 +9,7 @@ from fastapi.responses import JSONResponse
from config.custom_scene import get_rtc_openapi_version
from security.signer import Signer
from services.scene_service import Scenes, prepare_scene_runtime
from services.session_store import load_session
from utils.responses import error_response
from utils.validation import assert_scene_value, assert_value
@ -42,11 +43,25 @@ async def proxy(request: Request):
)
if action == "StartVoiceChat":
# 从 session 取回 getScenes 时分配的 RoomId/UserId/TaskId
sess = load_session(request, scene_id)
if sess.get("RoomId"):
voice_chat["RoomId"] = sess["RoomId"]
if sess.get("TaskId"):
voice_chat["TaskId"] = sess["TaskId"]
if sess.get("UserId"):
agent_config = voice_chat.get("AgentConfig", {})
target_user_ids = agent_config.get("TargetUserId", [])
if target_user_ids:
target_user_ids[0] = sess["UserId"]
else:
agent_config["TargetUserId"] = [sess["UserId"]]
req_body = voice_chat
elif action == "StopVoiceChat":
app_id = voice_chat.get("AppId", "")
room_id = voice_chat.get("RoomId", "")
task_id = voice_chat.get("TaskId", "")
sess = load_session(request, scene_id)
room_id = sess.get("RoomId") or voice_chat.get("RoomId", "")
task_id = sess.get("TaskId") or voice_chat.get("TaskId", "")
assert_scene_value(scene_id, "VoiceChat.AppId", app_id)
assert_scene_value(scene_id, "VoiceChat.RoomId", room_id)
assert_scene_value(scene_id, "VoiceChat.TaskId", task_id)

View File

@ -2,16 +2,17 @@
POST /getScenes 场景列表
"""
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from services.scene_service import Scenes, prepare_scene_runtime
from services.session_store import save_session
router = APIRouter()
@router.post("/getScenes")
async def get_scenes():
async def get_scenes(request: Request):
try:
scenes_list = []
for scene_name, data in Scenes.items():
@ -48,6 +49,14 @@ async def get_scenes():
)
rtc_out = {k: v for k, v in rtc_config.items() if k != "AppKey"}
rtc_out["TaskId"] = voice_chat.get("TaskId", "")
# 存储本次生成的房间信息,供后续 StartVoiceChat 使用
save_session(request, scene_name, {
"RoomId": rtc_config.get("RoomId", ""),
"UserId": rtc_config.get("UserId", ""),
"TaskId": voice_chat.get("TaskId", ""),
})
scenes_list.append(
{

View File

@ -6,10 +6,23 @@ from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: str
content: str
role: str = Field(default="user", description="消息角色: user / assistant / system")
content: str = Field(default="你好,请介绍一下你自己", description="消息内容")
class ChatCallbackRequest(BaseModel):
"""Custom LLM 回调请求体(带默认值方便 Swagger 调试)"""
messages: list[ChatMessage] = Field(
default=[
ChatMessage(role="user", content="你好,请介绍一下你自己"),
],
description="对话消息列表,最后一条必须是 user 角色",
)
temperature: float | None = Field(default=None, description="采样温度")
max_tokens: int | None = Field(default=None, description="最大生成 token 数")
top_p: float | None = Field(default=None, description="Top-P 采样")
class DebugChatRequest(BaseModel):
history: list[ChatMessage] = Field(default_factory=list)
question: str
question: str = Field(default="你好", description="用户问题")

View File

@ -1,13 +1,32 @@
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any, Iterator
from openai import OpenAI
from config.prompt_config import load_prompt
from tools import tool_registry
from utils.env import env_float, env_int, env_str
logger = logging.getLogger(__name__)
def _accumulate_tool_calls(buffer: dict[int, dict], delta_tool_calls: list) -> None:
"""将流式增量 tool_calls 合并到 buffer[index] 中。"""
for tc_delta in delta_tool_calls:
idx = tc_delta.index
if idx not in buffer:
buffer[idx] = {"id": tc_delta.id or "", "name": "", "arguments": ""}
if tc_delta.id:
buffer[idx]["id"] = tc_delta.id
fn = tc_delta.function
if fn:
if fn.name:
buffer[idx]["name"] += fn.name
if fn.arguments:
buffer[idx]["arguments"] += fn.arguments
DEFAULT_LLM_TIMEOUT_SECONDS = 1800
DEFAULT_LLM_TEMPERATURE = 0
@ -25,7 +44,6 @@ class LocalLLMSettings:
model: str
base_url: str
timeout_seconds: int
system_prompt: str
default_temperature: float
thinking_type: str
@ -49,7 +67,6 @@ def _load_settings() -> LocalLLMSettings:
timeout_seconds=env_int(
"LOCAL_LLM_TIMEOUT_SECONDS", DEFAULT_LLM_TIMEOUT_SECONDS
),
system_prompt=env_str("CUSTOM_LLM_SYSTEM_MESSAGE"),
default_temperature=env_float(
"LOCAL_LLM_TEMPERATURE", DEFAULT_LLM_TEMPERATURE
),
@ -84,24 +101,23 @@ class LocalLLMService:
def chat_stream(
self,
history_messages: list[dict[str, Any]],
rag_context: str = "",
request_options: dict[str, Any] | None = None,
prompt_name: str = "default",
) -> Iterator[Any]:
settings = self.settings
client = self._get_client()
request_options = request_options or {}
system_blocks = [settings.system_prompt]
if rag_context:
system_blocks.append(f"### 参考知识库(绝对准则)\n{rag_context.strip()}")
messages = [
{
"role": "system",
"content": "\n\n".join(block for block in system_blocks if block),
"content": load_prompt(prompt_name),
}
]
messages.extend(history_messages)
# 过滤掉 history 中已有的 system 消息,避免重复
messages.extend(
msg for msg in history_messages if msg.get("role") != "system"
)
payload: dict[str, Any] = {
"model": settings.model,
@ -119,18 +135,83 @@ class LocalLLMService:
if request_options.get("top_p") is not None:
payload["top_p"] = request_options["top_p"]
# thinking 仅在启用时通过 extra_body 传递(避免不支持的模型报错)
if settings.thinking_type != "disabled":
# thinking 配置通过 extra_body 传递
payload["extra_body"] = {"thinking": {"type": settings.thinking_type}}
payload["reasoning_effort"] = "low"
if settings.thinking_type != "disabled":
payload["reasoning_effort"] = "medium" # 可选值low / medium / high
# 注入 tools
tools_enabled = os.getenv("TOOLS_ENABLED", "false").lower() == "true"
tools_list = tool_registry.get_openai_tools() if tools_enabled else []
if tools_list:
payload["tools"] = tools_list
max_rounds = int(os.getenv("TOOLS_MAX_ROUNDS", "3"))
import json as _json
logger.info("发起流式调用 (Model: %s, Thinking: %s)", settings.model, settings.thinking_type)
logger.debug("请求 payload:\n%s", _json.dumps(payload, ensure_ascii=False, indent=2))
try:
for round_idx in range(max_rounds):
logger.info("LLM 调用第 %d 轮 (tools=%d)", round_idx + 1, len(tools_list))
tc_buffer: dict[int, dict] = {}
has_tool_calls = False
stream = client.chat.completions.create(**payload)
for chunk in stream:
if not chunk.choices:
if not has_tool_calls:
yield chunk
continue
choice = chunk.choices[0]
delta = choice.delta
# 收集 tool_calls 增量片段(不 yield 给客户端)
if delta and delta.tool_calls:
has_tool_calls = True
_accumulate_tool_calls(tc_buffer, delta.tool_calls)
continue
# 正常文本内容直接 yield
if not has_tool_calls:
yield chunk
if not tc_buffer:
break # 无 tool_calls结束循环
# 构造 assistant message含 tool_calls
sorted_tcs = sorted(tc_buffer.items())
tool_calls_for_msg = [
{
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": tc["arguments"]},
}
for _, tc in sorted_tcs
]
payload["messages"].append({
"role": "assistant",
"content": None,
"tool_calls": tool_calls_for_msg,
})
# 执行工具并追加结果
for tc in tool_calls_for_msg:
fn_name = tc["function"]["name"]
fn_args = tc["function"]["arguments"]
logger.info("执行工具: %s(%s)", fn_name, fn_args)
result = tool_registry.execute(fn_name, fn_args)
logger.info("工具结果: %s%s", fn_name, result[:200])
payload["messages"].append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
# 继续下一轮 LLM 调用
except Exception as exc:
logger.error("LLM 调用失败: %s", exc)
raise

View File

@ -0,0 +1,35 @@
"""
轻量级服务端 Session 存储
以客户端 IP + User-Agent 的哈希值作为 session key
无需 cookie无需前端改动
每次 getScenes 写入StartVoiceChat/StopVoiceChat 读取
"""
import hashlib
from fastapi import Request
# { session_key: { scene_id: { RoomId, UserId, TaskId } } }
_store: dict[str, dict[str, dict]] = {}
def _key(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
ip = (
forwarded.split(",")[0].strip()
if forwarded
else (request.client.host if request.client else "unknown")
)
ua = request.headers.get("User-Agent", "")
return hashlib.sha256(f"{ip}:{ua}".encode()).hexdigest()
def save_session(request: Request, scene_id: str, data: dict) -> None:
key = _key(request)
if key not in _store:
_store[key] = {}
_store[key][scene_id] = data
def load_session(request: Request, scene_id: str) -> dict:
return _store.get(_key(request), {}).get(scene_id, {})

View File

@ -0,0 +1,6 @@
from tools.registry import tool_registry, ToolRegistry
# 自动加载内置工具(触发 @tool_registry.register 装饰器)
import tools.builtin # noqa: F401
__all__ = ["tool_registry", "ToolRegistry"]

View File

@ -0,0 +1,4 @@
from tools.builtin import api_tools # noqa: F401
from tools.builtin import current_time # noqa: F401
from tools.builtin import weather # noqa: F401
from tools.builtin import rag_tool # noqa: F401

View File

@ -0,0 +1,204 @@
"""
可声明式批量注册的 HTTP API 工具
添加新接口只需在 API_ENDPOINTS 列表中追加一条配置无需编写函数代码
URL 中支持 {param} 路径参数GET 自动走 query stringPOST/PUT 自动走 JSON body
"""
import json
import re
import httpx
from tools.registry import tool_registry
# ── 公共默认值(所有接口共享) ─────────────────────────────
DEFAULT_BASE_URL = "https://hskuaikuai.com:9000/test" # TODO: 替换为实际服务地址
DEFAULT_HEADERS = {
"Content-Type": "application/json",
"token": "265a068eb7af455ca97f1a5063561a5d", # TODO: 后续由前端传入
}
DEFAULT_TIMEOUT = 10 # 秒
# ── API 端点声明 ─────────────────────────────────────────
#
# 字段说明:
# name ─ 工具名称(英文,唯一)
# description ─ 工具描述LLM 据此判断何时调用)
# method ─ HTTP 方法GET / POST / PUT / DELETE
# path ─ 请求路径(自动拼接 DEFAULT_BASE_URL可含 {param} 占位符
# headers ─ 可选,额外请求头(与 DEFAULT_HEADERS 合并)
# parameters ─ OpenAI function calling parameters schema
#
# 路由规则:
# - path 自动拼接 DEFAULT_BASE_URL
# - {param} 占位符 → 从参数中取出并替换到 URL
# - GET → 剩余参数作为 query string
# - POST / PUT → 剩余参数作为 JSON body
#
API_ENDPOINTS: list[dict] = [
# ── 示例:签到汇总 ──
{
"name": "checkin_sum",
"description": "查询出勤人数",
"method": "POST",
"path": "/checkin/sum",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
# ── APP获取不同部门考勤打卡信息 ──
{
"name": "get_checkin_app_dept",
"description": "获取不同部门的考勤打卡信息包括出勤率、总人数、打卡人数等。deptName传'全部'可一次查询所有部门汇总。",
"method": "POST",
"path": "/checkin/app/dept",
"parameters": {
"type": "object",
"properties": {
"deptName": {
"type": "string",
"description": "部门名称,传'全部'查所有部门",
"enum": ["养护部", "工程部", "环卫部", "办公室", "全部"],
},
},
"required": ["deptName"],
},
},
]
def _summarize_checkin_dept(raw: str) -> str:
"""将 get_checkin_app_dept 的完整返回精简为摘要,大幅减少 token 消耗。"""
try:
obj = json.loads(raw)
data = obj.get("data")
if not data or "workerList" not in data:
return raw
workers = data["workerList"]
attendance = data.get("attendance", "")
worker_num = data.get("workerNum", 0)
checkin_num = data.get("checkinNum", 0)
# 最早打卡
checked_in = [w for w in workers if w.get("checkin")]
earliest = None
if checked_in:
checked_in.sort(key=lambda w: w["checkin"])
e = checked_in[0]
t = e["checkin"].split("T")[1][:5] if "T" in e["checkin"] else e["checkin"]
earliest = f"{e['name']} {t}({e.get('deptName', '')})"
# 未打卡/请假
un_checked = [
f"{w['name']}({w.get('checkinState', '未打卡')})"
for w in workers if not w.get("checkin")
]
# 各子部门打卡人数汇总
dept_stats = {}
for w in workers:
dept = w.get("deptName", "未知")
if dept not in dept_stats:
dept_stats[dept] = {"total": 0, "checked": 0}
dept_stats[dept]["total"] += 1
if w.get("checkin"):
dept_stats[dept]["checked"] += 1
summary = {
"出勤率": f"{attendance}%",
"总人数": worker_num,
"已打卡": checkin_num,
"最早到岗": earliest,
"未到岗": un_checked if un_checked else "全部已打卡",
"子部门": {k: f"{v['checked']}/{v['total']}" for k, v in dept_stats.items()},
}
return json.dumps(summary, ensure_ascii=False)
except Exception:
return raw
# 工具名 → 后处理函数
_RESULT_SUMMARIZERS = {
"get_checkin_app_dept": _summarize_checkin_dept,
}
# ── 通用请求执行器 ────────────────────────────────────────
_PATH_PARAM_RE = re.compile(r"\{(\w+)\}")
def _call_api(endpoint: dict, **kwargs) -> str:
method = endpoint["method"].upper()
headers = {**DEFAULT_HEADERS, **endpoint.get("headers", {})}
# "全部"部门:批量查询所有部门,合并摘要后返回
if endpoint["name"] == "get_checkin_app_dept" and kwargs.get("deptName") == "全部":
all_depts = ["养护部", "工程部", "环卫部", "办公室"]
combined = {}
for dept in all_depts:
try:
url = DEFAULT_BASE_URL.rstrip("/") + endpoint["path"]
resp = httpx.request(method, url, headers=headers, json={"deptName": dept}, timeout=DEFAULT_TIMEOUT)
resp.raise_for_status()
summary = _summarize_checkin_dept(resp.text)
combined[dept] = json.loads(summary)
except Exception as exc:
combined[dept] = f"查询失败: {exc}"
result = json.dumps(combined, ensure_ascii=False)
print(f"全部部门摘要: {result[:300]}...")
return result
url = DEFAULT_BASE_URL.rstrip("/") + endpoint["path"]
# 1) 路径参数替换:{user_id} → kwargs["user_id"]
path_params = set(_PATH_PARAM_RE.findall(url))
for p in path_params:
value = kwargs.pop(p, "")
url = url.replace(f"{{{p}}}", str(value))
# 2) 分流GET → query stringPOST/PUT/DELETE → JSON body
try:
if method == "GET":
resp = httpx.request(
method, url, headers=headers, params=kwargs, timeout=DEFAULT_TIMEOUT
)
else:
resp = httpx.request(
method, url, headers=headers, json=kwargs or None, timeout=DEFAULT_TIMEOUT
)
resp.raise_for_status()
result = resp.text
print(f"API 调用成功 ({endpoint['name']}): {result[:200]}...")
# 对特定工具的返回做摘要,减少 LLM 上下文消耗
summarizer = _RESULT_SUMMARIZERS.get(endpoint["name"])
if summarizer:
result = summarizer(result)
print(f"摘要后 ({endpoint['name']}): {result}")
return result
except Exception as exc:
return f"API 调用失败 ({endpoint['name']}): {exc}"
# ── 自动注册所有声明的端点 ────────────────────────────────
for _ep in API_ENDPOINTS:
def _make_handler(ep=_ep):
def handler(**kw) -> str:
return _call_api(ep, **kw)
return handler
tool_registry.register(
name=_ep["name"],
description=_ep["description"],
parameters=_ep["parameters"],
)(_make_handler())

View File

@ -0,0 +1,12 @@
from datetime import datetime
from tools.registry import tool_registry
@tool_registry.register(
name="get_current_time",
description="获取当前日期和时间",
parameters={"type": "object", "properties": {}},
)
def get_current_time() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S %A")

View File

@ -0,0 +1,128 @@
"""
知识库检索工具 RAG 作为 Function Calling 工具注册
LLM 按需调用而非每次请求都预检索
"""
import json
import logging
import httpx
from security.signer import Signer
from tools.registry import tool_registry
from utils.env import env_bool, env_int, env_str
logger = logging.getLogger(__name__)
_KB_API_PATH = "/api/knowledge/collection/search_knowledge"
_KB_SERVICE = "air"
_KB_REGION = "cn-beijing"
def _retrieve_sync(query: str) -> str:
"""同步调用火山引擎知识库检索 API返回格式化后的知识片段。"""
access_key = env_str("CUSTOM_ACCESS_KEY_ID")
secret_key = env_str("CUSTOM_SECRET_KEY")
if not access_key or not secret_key:
return "知识库凭证未配置,无法检索"
endpoint = env_str("VOLC_KB_ENDPOINT", "https://api-knowledgebase.mlp.cn-beijing.volces.com")
kb_resource_id = env_str("VOLC_KB_RESOURCE_ID")
kb_name = env_str("VOLC_KB_NAME")
kb_project = env_str("VOLC_KB_PROJECT", "default")
top_k = env_int("VOLC_KB_TOP_K", 3)
rerank = env_bool("VOLC_KB_RERANK", False)
attachment_link = env_bool("VOLC_KB_ATTACHMENT_LINK", False)
if not kb_resource_id and not kb_name:
return "知识库 ID 未配置,无法检索"
body: dict = {"query": query, "limit": top_k}
if kb_resource_id:
body["resource_id"] = kb_resource_id
else:
body["name"] = kb_name
body["project"] = kb_project
post_processing: dict = {}
if rerank:
post_processing["rerank_switch"] = True
if attachment_link:
post_processing["get_attachment_link"] = True
if post_processing:
body["post_processing"] = post_processing
headers = {"Content-Type": "application/json"}
signer = Signer(
request_data={
"region": _KB_REGION,
"method": "POST",
"path": _KB_API_PATH,
"headers": headers,
"body": body,
},
service=_KB_SERVICE,
)
signer.add_authorization({"accessKeyId": access_key, "secretKey": secret_key})
body_bytes = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
url = endpoint.rstrip("/") + _KB_API_PATH
resp = httpx.post(url, headers=headers, content=body_bytes, timeout=15)
resp_data = resp.json()
if resp_data.get("code") != 0:
msg = resp_data.get("message", "未知错误")
logger.warning("知识库检索失败: code=%s, message=%s", resp_data.get("code"), msg)
return f"知识库检索失败: {msg}"
result_list = resp_data.get("data", {}).get("result_list", [])
if not result_list:
return "知识库中未找到相关内容"
# 格式化为简洁文本
parts = []
for i, chunk in enumerate(result_list, start=1):
title = chunk.get("chunk_title") or ""
content = chunk.get("content") or ""
header = f"[{i}] {title}" if title else f"[{i}]"
block = f"{header}\n{content}"
if attachment_link:
attachments = chunk.get("chunk_attachment") or []
image_links = [a["link"] for a in attachments if a.get("link")]
if image_links:
block += "\n附件: " + ", ".join(image_links)
parts.append(block)
result = "\n\n".join(parts)
logger.info("知识库检索到 %d 条结果 (query=%s)", len(result_list), query[:50])
return result
# ── 注册工具 ──
if env_bool("VOLC_KB_ENABLED", False):
@tool_registry.register(
name="search_knowledge",
description="从知识库检索相关内容,用于回答系统功能介绍、操作说明、规章制度等问题。当用户询问的问题涉及业务知识而非实时数据时使用。",
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "检索关键词或问题",
},
},
"required": ["query"],
},
)
def search_knowledge(query: str) -> str:
try:
return _retrieve_sync(query)
except Exception as exc:
logger.exception("知识库检索异常")
return f"知识库检索出错: {exc}"
logger.info("✅ RAG 知识库工具已注册 (resource_id=%s)", env_str("VOLC_KB_RESOURCE_ID"))

View File

@ -0,0 +1,23 @@
import httpx
from tools.registry import tool_registry
@tool_registry.register(
name="query_weather",
description="查询指定城市的实时天气信息",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如:北京、上海"},
},
"required": ["city"],
},
)
def query_weather(city: str) -> str:
try:
resp = httpx.get(f"https://wttr.in/{city}?format=3", timeout=3)
resp.raise_for_status()
return resp.text.strip()
except Exception as exc:
return f"天气查询失败:{exc}"

63
backend/tools/registry.py Normal file
View File

@ -0,0 +1,63 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Callable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ToolDef:
name: str
description: str
parameters: dict[str, Any]
handler: Callable[..., str]
class ToolRegistry:
def __init__(self):
self._tools: dict[str, ToolDef] = {}
def register(self, name: str, description: str, parameters: dict[str, Any]):
"""装饰器:将函数注册为工具。"""
def decorator(fn: Callable[..., str]) -> Callable[..., str]:
self._tools[name] = ToolDef(
name=name,
description=description,
parameters=parameters,
handler=fn,
)
logger.info("工具已注册: %s", name)
return fn
return decorator
def get_openai_tools(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in self._tools.values()
]
def execute(self, name: str, arguments_json: str) -> str:
tool = self._tools.get(name)
if tool is None:
return f"错误:未知工具 '{name}'"
try:
kwargs = json.loads(arguments_json) if arguments_json.strip() else {}
return tool.handler(**kwargs)
except Exception as exc:
logger.exception("工具 %s 执行失败", name)
return f"工具 {name} 执行出错:{exc}"
tool_registry = ToolRegistry()

View File

@ -1,132 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"airbnb",
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true
},
"sourceType": "module"
},
"plugins": ["react", "babel", "@typescript-eslint/eslint-plugin"],
"globals": {
"ActiveXObject": false,
"describe": false,
"it": false,
"expect": false,
"jest": false,
"$": false,
"afterEach": false,
"beforeEach": false
},
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"rules": {
"@typescript-eslint/no-unused-vars": [2, { "args": "none" }],
"@typescript-eslint/no-use-before-define": [2, { "functions": false, "classes": false }]
}
}
],
"rules": {
"prettier/prettier": ["warn", { "trailingComma": "es5", "printWidth": 100 }],
"linebreak-style": "off",
"no-console": ["warn", { "allow": ["warn", "error", "log"] }],
"no-case-declarations": 0,
"no-param-reassign": 0,
"no-underscore-dangle": 0,
"no-useless-constructor": 0,
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
"no-restricted-syntax": 0,
"no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }],
"no-plusplus": 0,
"no-return-assign": 0,
"no-script-url": 0,
"no-extend-native": 0,
"no-restricted-globals": 0,
"no-nested-ternary": 0,
"no-empty": 0,
"no-void": 0,
"no-useless-escape": 0,
"no-bitwise": 0,
"no-mixed-operators": 0,
"consistent-return": 0,
"one-var": 0,
"prefer-promise-reject-errors": 0,
"prefer-destructuring": 0,
"global-require": 0,
"guard-for-in": 0,
"func-names": 0,
"strict": 0,
"radix": 0,
"no-prototype-builtins": 0,
"class-methods-use-this": 0,
"import/no-dynamic-require": 0,
"import/no-unresolved": 0,
"import/extensions": 0,
"import/no-extraneous-dependencies": 0,
"import/prefer-default-export": 0,
"import/no-absolute-path": 0,
"react/no-danger": 0,
"react/forbid-prop-types": 0,
"react/prop-types": 0,
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", "ts", "tsx"] }],
"react/sort-comp": 0,
"react/no-did-update-set-state": 0,
"react/prefer-stateless-function": 0,
"react/jsx-closing-tag-location": 0,
"react/jsx-no-bind": 0,
"react/no-array-index-key": 0,
"react/no-children-prop": 0,
"react/no-did-mount-set-state": 0,
"react/no-find-dom-node": 0,
"react/default-props-match-prop-types": 0,
"react/jsx-one-expression-per-line": 0,
"react/react-in-jsx-scope": 0,
"react/jsx-props-no-spreading": 0,
"jsx-a11y/anchor-is-valid": 0,
"jsx-a11y/no-static-element-interactions": 0,
"jsx-a11y/click-events-have-key-events": 0,
"jsx-a11y/no-noninteractive-element-interactions": 0,
"jsx-a11y/alt-text": 0,
"jsx-a11y/label-has-for": 0,
"jsx-a11y/label-has-associated-control": 0,
"jsx-a11y/no-noninteractive-tabindex": 0,
"jsx-a11y/tabindex-no-positive": 0,
"react/jsx-indent": 0,
"react/display-name": 0,
"react/no-multi-comp": 0,
"react/destructuring-assignment": 0,
"react/no-access-state-in-setstate": 0,
"react/button-has-type": 0,
"react/require-default-props": 0,
"react/jsx-wrap-multilines": 0,
"react/no-render-return-value": 0,
"array-callback-return": 0,
"no-cond-assign": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"no-use-before-define": 0,
"@typescript-eslint/no-use-before-define": 2,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-empty-function": 0,
"no-shadow": 0,
"no-continue": 0,
"no-loop-func": 0,
"prefer-spread": 0,
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-undef": 0
}
}

View File

@ -1 +0,0 @@
registry = 'https://registry.npmjs.org/'

View File

@ -1,10 +0,0 @@
{
"arrowParens": "always",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"printWidth": 100,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@ -1,25 +0,0 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"customSyntax": "postcss-less",
"rules": {
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"font-family-no-missing-generic-family-keyword": null,
"block-opening-brace-space-before": "always",
"declaration-block-trailing-semicolon": null,
"declaration-colon-newline-after": null,
"indentation": null,
"selector-descendant-combinator-no-non-space": null,
"selector-class-pattern": null,
"keyframes-name-pattern": null,
"no-invalid-position-at-import-rule": null,
"number-max-precision": 6,
"color-function-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
}
}

View File

@ -1,27 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
const CracoLessPlugin = require('craco-less');
const path = require('path');
module.exports = {
webpack: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
javascriptEnabled: true,
},
},
},
},
],
};

View File

@ -1,12 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
const reset = '\x1b[0m';
const bright = '\x1b[1m';
const green = '\x1b[32m';
console.log(`${bright}${bright}===================================================`);
console.log(`${bright}${green}| 请查看目录下的 README.md 内容, 否则启动可能失败 |`);
console.log(`${bright}${reset}===================================================${reset}`);

20491
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,71 +0,0 @@
{
"name": "aigc",
"version": "1.6.0",
"license": "BSD-3-Clause",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^1.8.3",
"@volcengine/rtc": "~4.66.20",
"@arco-design/web-react": "^2.65.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.2",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"redux": "^4.2.0",
"uuid": "^8.3.2"
},
"scripts": {
"dev": "npm run echo && npm run start",
"start": "cross-env REACT_APP_LOCAL=cn craco start",
"server:start": "node Server/app.js",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"eslint": "eslint src/ --fix --cache --quiet --ext .js,.jsx,.ts,.tsx",
"stylelint": "stylelint 'src/**/*.less' --fix",
"pre-commit": "npm run eslint && npm run stylelint",
"echo": "node message.js"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@craco/craco": "^6.4.5",
"@types/lodash": "^4.17.4",
"@types/node": "^16.11.45",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/react-helmet": "^6.1.11",
"@types/uuid": "^8.3.4",
"craco-less": "^2.0.0",
"cross-env": "^7.0.3",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-prettier": "^4.2.1",
"postcss-less": "^6.0.0",
"prettier": "^2.7.1",
"react-scripts": "5.0.1",
"stylelint": "^14.9.1",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^26.0.0",
"typescript": "^4.7.4"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,38 +0,0 @@
<!--
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
-->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="volc video demo" />
<link rel="icon" href="/favicon.png" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>火山引擎 RTC 实时对话式 AI 体验 Demo ——— 支持 DeepSeek 和 豆包视觉理解模型</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,24 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import MainPage from './pages/MainPage';
import '@arco-design/web-react/dist/css/arco.css';
function App() {
console.warn('运行问题可参考 README 内容进行排查');
return (
<BrowserRouter>
<Routes>
<Route path="/">
<Route index element={<MainPage />} />
<Route path="/*" element={<MainPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -1,31 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
/**
* @brief Basic APIs
*/
export const BasicAPIs = [
{
action: 'getScenes',
apiPath: '/getScenes',
method: 'post',
},
] as const;
/**
* @brief Basic APIs
*/
export const AigcAPIs = [
{
action: 'StartVoiceChat',
apiPath: '/proxy',
method: 'post',
},
{
action: 'StopVoiceChat',
apiPath: '/proxy',
method: 'post',
},
] as const;

View File

@ -1,112 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { Message } from '@arco-design/web-react';
import { AIGC_PROXY_HOST } from '@/config';
import type { RequestResponse, ApiConfig, ApiNames, Apis } from './type';
type Headers = Record<string, string>;
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
/**
* @brief Get
* @param apiName
* @param headers
*/
export const requestGetMethod = ({
action,
headers = {},
}: {
action: string;
headers?: Record<string, string>;
}) => {
return async (params: Record<string, any> = {}) => {
const url = `${AIGC_PROXY_HOST}?Action=${action}&${Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.join('&')}`;
const res = await fetch(url, {
headers: {
...headers,
},
});
return res;
};
};
/**
* @brief Post
*/
export const requestPostMethod = ({
action,
apiPath,
isJson = true,
headers = {},
}: {
action: string;
apiPath: string;
isJson?: boolean;
headers?: Headers;
}) => {
return async <T>(params: T) => {
const res = await fetch(`${AIGC_PROXY_HOST}${apiPath}?Action=${action}`, {
method: 'post',
headers: {
'content-type': 'application/json',
...headers,
},
body: (isJson ? JSON.stringify(params) : params) as BodyInit,
});
return res;
};
};
/**
* @brief Return handler
* @param res
*/
export const resultHandler = (res: RequestResponse) => {
const { Result, ResponseMetadata } = res || {};
// Record request id for debug.
if (ResponseMetadata.Action === 'StartVoiceChat') {
const requestId = ResponseMetadata.RequestId;
requestId && sessionStorage.setItem('RequestID', requestId);
}
if (ResponseMetadata.Error) {
Message.error(
`[${ResponseMetadata?.Action}]call failed(reason: ${ResponseMetadata.Error?.Message})`
);
throw new Error(
`[${ResponseMetadata?.Action}]call failed(${JSON.stringify(ResponseMetadata, null, 2)})`
);
}
return Result;
};
/**
* @brief Generate APIs by apiConfigs
* @param apiConfigs
*/
export const generateAPIs = <T extends readonly ApiConfig[]>(apiConfigs: T) =>
apiConfigs.reduce<Apis<T>>((store, cur) => {
const { action, apiPath = '', method = 'get' } = cur;
const actionKey = action as ApiNames<T>;
store[actionKey] = async (params) => {
const queryData =
method === 'get'
? await requestGetMethod({ action })(params)
: await requestPostMethod({ action, apiPath })(params);
const res = await queryData?.json();
return resultHandler(res);
};
return store;
}, {} as Apis<T>);

View File

@ -1,15 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { AigcAPIs, BasicAPIs } from './api';
import { generateAPIs } from './base';
const VoiceChat = generateAPIs(AigcAPIs);
const Basic = generateAPIs(BasicAPIs);
export default {
VoiceChat,
Basic,
};

View File

@ -1,34 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
export type RequestParams = Record<string, any>;
export interface RequestResponse {
ResponseMetadata: Partial<{
Action: string;
Version: string;
Service: string;
Region: string;
RequestId: string;
Error: {
Code: string;
Message: string;
};
}>;
Result: any;
}
type TupleToUnion<T extends readonly unknown[]> = T[number];
type RequestFn = <T extends keyof RequestResponse>(params?: RequestParams[T]) => RequestResponse[T];
type PromiseRequestFn = <T extends keyof RequestResponse>(
params?: RequestParams[T]
) => Promise<RequestResponse[T]>;
export type ApiConfig = { action: string; method: string; apiPath?: string };
export type ApiNames<T extends readonly ApiConfig[]> = TupleToUnion<T>['action'];
export type Apis<T extends readonly ApiConfig[]> = Record<
ApiNames<T>,
RequestFn | PromiseRequestFn
>;

View File

@ -1,37 +0,0 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_2426_337170)">
<circle cx="40" cy="40.0001" r="40" fill="url(#paint0_linear_2426_337170)"/>
<circle cx="40" cy="40.0001" r="40" fill="url(#paint1_radial_2426_337170)" fill-opacity="0.8"/>
<circle cx="40" cy="40.0001" r="40" fill="url(#paint2_radial_2426_337170)" fill-opacity="0.8"/>
<circle cx="40" cy="40.0001" r="40" fill="url(#paint3_radial_2426_337170)" fill-opacity="0.8"/>
</g>
<defs>
<filter id="filter0_i_2426_337170" x="-7.43727" y="0.00012207" width="87.4373" height="80" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-7.43727"/>
<feGaussianBlur stdDeviation="7.43727"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.80575 0 0 0 0 0.7375 0 0 0 0 1 0 0 0 0.3 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2426_337170"/>
</filter>
<linearGradient id="paint0_linear_2426_337170" x1="72.5" y1="4.37512" x2="3.30456" y2="86.1152" gradientUnits="userSpaceOnUse">
<stop stop-color="#3C73FF"/>
<stop offset="0.527788" stop-color="#6E41EE"/>
<stop offset="0.880184" stop-color="#D641EE"/>
</linearGradient>
<radialGradient id="paint1_radial_2426_337170" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21.6949 -17.3912) rotate(96.1584) scale(97.9566 163.285)">
<stop stop-color="#52B6FF"/>
<stop offset="1" stop-color="#8F41EE" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint2_radial_2426_337170" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21.3559 4.6378) rotate(62.2005) scale(39.9762 114.742)">
<stop stop-color="#9DD6FF"/>
<stop offset="1" stop-color="#8F41EE" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint3_radial_2426_337170" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117.966 61.1595) rotate(144.328) scale(67.5991 92.9769)">
<stop stop-color="#5263FF"/>
<stop offset="1" stop-color="#8F41EE" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,5 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="48" width="48" height="48" rx="24" transform="rotate(-90 0 48)" fill="#F2F4F6"/>
<path d="M27.7029 31.1404C27.6056 31.492 27.2971 31.7561 26.9216 31.7839L26.8552 31.7859H14.53C14.0659 31.7859 13.6848 31.4269 13.6511 30.9714L13.6492 30.905V18.5798C13.6492 18.1756 13.9214 17.8336 14.2927 17.7302L27.7029 31.1404ZM32.4324 20.2595C33.0778 19.9075 33.899 20.3302 33.8992 21.0144V28.4695C33.8992 29.0362 33.3363 29.4221 32.7761 29.3386L29.0564 25.6189V22.1013L32.4324 20.2595ZM26.8552 17.699C27.3193 17.699 27.7004 18.058 27.7341 18.5134L27.7361 18.5798V24.2986L21.1365 17.699H26.8552Z" fill="#F53F3F"/>
<path d="M15.2998 15.3007L32.6998 32.7007" stroke="#F53F3F" stroke-width="1.215" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 841 B

View File

@ -1,12 +0,0 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2619_63436)">
<path d="M0.600586 30C0.600586 46.2331 13.7617 59.4 30.0006 59.4C46.2395 59.4 59.4006 46.2389 59.4006 30C59.4006 13.7611 46.2395 0.600006 30.0006 0.600006C13.7617 0.600006 0.600586 13.7611 0.600586 30Z" fill="#A2AFC3" fill-opacity="0.4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8256 19.5512C13.2054 19.7226 12.75 20.291 12.75 20.9657V41.5092L12.754 41.6187C12.81 42.378 13.4438 42.9766 14.2174 42.9766H34.7609L34.8704 42.9726C35.4965 42.9264 36.0133 42.4874 36.1754 41.901L13.8256 19.5512ZM44.6305 38.901L38.4287 32.6992V26.8349L44.0548 23.7662C45.1307 23.1794 46.4994 23.8839 46.4994 25.0245V37.4497C46.4994 38.3931 45.5631 39.0382 44.6305 38.901ZM36.2283 30.4988L25.2278 19.4984H34.7609C35.5345 19.4984 36.1682 20.097 36.2242 20.8562L36.2283 20.9657V30.4988Z" fill="#F3F7FF"/>
<path d="M15.501 15.5011L44.501 44.5011" stroke="#F3F7FF" stroke-width="2.025" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_2619_63436">
<rect width="58.8" height="58.8" fill="white" transform="translate(0.599609 0.600006)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,5 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 48C10.7452 48 -4.69686e-07 37.2548 -1.04907e-06 24C-1.62846e-06 10.7452 10.7452 3.34501e-06 24 2.76562e-06C37.2548 2.18624e-06 48 10.7452 48 24C48 37.2548 37.2548 48 24 48Z" fill="#F2F4F6"/>
<path d="M27.1738 16.9989C27.6896 16.9989 28.1121 17.398 28.1494 17.9042L28.1523 17.9774V31.6727C28.1523 32.1884 27.7532 32.6109 27.2471 32.6483L27.1738 32.6512H13.4785C12.9629 32.6512 12.5404 32.2521 12.5029 31.746L12.5 31.6727V17.9774C12.5 17.4617 12.8991 17.0392 13.4053 17.0018L13.4785 16.9989H27.1738ZM33.3701 19.8446C34.0874 19.4534 35 19.9231 35 20.6835V28.9667C35 29.7271 34.0874 30.1967 33.3701 29.8055L29.6191 27.7596V21.8905L33.3701 19.8446Z" fill="#737A87"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 20C19.1046 20 20 20.8954 20 22C20 23.1046 19.1046 24 18 24C16.8954 24 16 23.1046 16 22C16 20.8954 16.8954 20 18 20Z" fill="#F2F4F6"/>
</svg>

Before

Width:  |  Height:  |  Size: 965 B

View File

@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14L13 17L18.5 11.5" stroke="white" stroke-width="2.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 8C4 5.79086 5.79086 4 8 4H24V16C24 20.4183 20.4183 24 16 24H4V8Z" fill="#6D61FC"/>
<path d="M10 13.5L13 16.5L18.5 11" stroke="white" stroke-width="2.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 440 B

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9197 5.91974C13.7245 5.72448 13.4079 5.72448 13.2126 5.91974L10.1218 9.01051L6.90922 5.79788C6.71396 5.60262 6.39738 5.60262 6.20212 5.79788L5.84856 6.15144C5.6533 6.3467 5.6533 6.66328 5.84856 6.85854L9.06118 10.0712L5.84866 13.2837C5.6534 13.4789 5.65339 13.7955 5.84866 13.9908L6.20221 14.3443C6.39747 14.5396 6.71406 14.5396 6.90932 14.3443L10.1218 11.1318L13.2125 14.2225C13.4078 14.4178 13.7244 14.4178 13.9196 14.2225L14.2732 13.869C14.4684 13.6737 14.4684 13.3571 14.2732 13.1619L11.1825 10.0712L14.2733 6.9804C14.4686 6.78513 14.4686 6.46855 14.2733 6.27329L13.9197 5.91974Z" fill="#4E5969"/>
</svg>

Before

Width:  |  Height:  |  Size: 758 B

View File

@ -1,4 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 48C10.7452 48 -4.69686e-07 37.2548 -1.04907e-06 24C-1.62846e-06 10.7452 10.7452 3.34501e-06 24 2.76562e-06C37.2548 2.18624e-06 48 10.7452 48 24C48 37.2548 37.2548 48 24 48Z" fill="#F2F4F6"/>
<path d="M33.5579 22.5578C32.7192 21.719 31.0776 21.1949 29.848 20.9031C28.2015 20.5121 26.2355 20.2831 24.3123 20.258H24.3053L23.6866 20.2581C21.7634 20.283 19.7974 20.5122 18.1508 20.9031C16.9213 21.195 15.2796 21.719 14.4409 22.5578C13.5433 23.4554 13.2325 25.0499 13.1405 25.6937C13.0687 26.1961 12.8842 27.8853 13.5234 28.5245C13.7858 28.7869 14.2403 28.8978 14.9971 28.884C15.6663 28.8719 16.405 28.7638 16.9069 28.6752C17.5808 28.5563 18.2524 28.3979 18.7983 28.2292C19.6821 27.956 19.9654 27.7433 20.1017 27.607C20.3767 27.332 20.4716 26.9498 20.3837 26.4712C20.3259 26.1561 20.1988 25.8353 20.0761 25.5252C19.9932 25.316 19.8608 24.982 19.8428 24.8222C20.0053 24.7218 20.4582 24.54 21.3629 24.3685C22.2058 24.2086 23.1907 24.1069 23.9995 24.0961C24.808 24.1068 25.793 24.2084 26.6361 24.3685C27.5404 24.54 27.9934 24.7217 28.156 24.8222C28.1379 24.982 28.0057 25.3159 27.9228 25.5251C27.7999 25.8352 27.6728 26.156 27.615 26.4712C27.5272 26.9498 27.6221 27.332 27.8972 27.607C28.0334 27.7432 28.3168 27.956 29.2006 28.2292C29.7464 28.3979 30.4181 28.5563 31.0919 28.6752C31.5938 28.7637 32.3326 28.8719 33.0018 28.8841C33.7585 28.8978 34.2129 28.7869 34.4754 28.5245C35.1146 27.8853 34.9301 26.1962 34.8583 25.6938C34.7663 25.0499 34.4555 23.4554 33.5579 22.5578Z" fill="#F53F3F"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -1,5 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="48" width="48" height="48" rx="24" transform="rotate(-90 0 48)" fill="#F2F4F6"/>
<path d="M14.3063 27.7765C14.7074 27.499 15.2581 27.599 15.5358 28.0001C16.6678 29.6356 18.5767 30.712 20.7457 30.712C22.7716 30.7119 24.5694 29.7722 25.7203 28.3156L26.9772 29.5695C25.6965 31.1075 23.8439 32.1635 21.7408 32.4191V33.3654C21.7406 33.8531 21.3449 34.2482 20.857 34.2482C20.3693 34.2481 19.9735 33.8531 19.9733 33.3654V32.4435C17.5279 32.2142 15.3982 30.9065 14.0826 29.006C13.805 28.6048 13.9051 28.0542 14.3063 27.7765ZM24.0856 26.6866C23.2275 27.5365 22.0479 28.0626 20.7447 28.0626C18.1221 28.0624 15.9957 25.9363 15.9957 23.3136V18.6749C15.9957 18.657 15.9965 18.6391 15.9967 18.6212L24.0856 26.6866ZM31.8033 16.1486C32.2303 15.9124 32.7673 16.0673 33.0035 16.4943C33.726 17.8001 34.1097 19.2744 34.108 20.7814C34.1062 22.3358 33.6947 23.8554 32.9244 25.1896C32.6803 25.6119 32.1399 25.7567 31.7174 25.5128C31.295 25.2688 31.1504 24.7283 31.3942 24.3058C32.0104 23.2385 32.34 22.0229 32.3414 20.7794C32.3428 19.5739 32.0356 18.3944 31.4576 17.3497C31.2214 16.9228 31.3764 16.3848 31.8033 16.1486ZM28.442 18.2159C28.8791 17.9991 29.4097 18.1773 29.6266 18.6144C29.957 19.2803 30.1323 20.0187 30.1324 20.7736C30.1326 21.6325 29.9058 22.4701 29.482 23.2042C29.2381 23.6268 28.6976 23.7713 28.275 23.5275C27.8524 23.2835 27.7078 22.743 27.9518 22.3204C28.2214 21.8533 28.3659 21.32 28.3658 20.7736C28.3657 20.2933 28.2538 19.8233 28.0436 19.3995C27.8269 18.9625 28.0051 18.4329 28.442 18.2159ZM25.2711 19.671C25.8809 19.6711 26.3755 20.1657 26.3756 20.7755C26.3756 21.3854 25.881 21.8799 25.2711 21.88C24.6611 21.88 24.1666 21.3855 24.1666 20.7755C24.1667 20.1656 24.6612 19.671 25.2711 19.671ZM20.7447 13.9259C23.1062 13.9259 25.0653 15.6495 25.4322 17.9073C25.3796 17.9045 25.3265 17.9034 25.2731 17.9034C23.9643 17.9034 22.8622 18.7794 22.5162 19.9767L17.6227 15.0978C18.4575 14.3685 19.5492 13.926 20.7447 13.9259Z" fill="#FF3A2F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4085 14.0473C13.2323 14.2235 13.2323 14.5098 13.4085 14.686L31.295 32.5725C31.4716 32.7491 31.7575 32.7491 31.9341 32.5725L32.5728 31.9337C32.749 31.7571 32.749 31.4712 32.5728 31.295L30.4742 29.1964L29.1927 27.9145L27.9072 26.6294L26.6117 25.3335L24.327 23.0492L22.5202 21.2424L20.7012 19.423L19.4125 18.1347L14.6864 13.4086C14.5097 13.232 14.2238 13.232 14.0476 13.4086L13.4085 14.0473Z" fill="#FF3A2F"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,8 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 48C10.7452 48 -4.69686e-07 37.2548 -1.04907e-06 24C-1.62846e-06 10.7452 10.7452 3.34501e-06 24 2.76562e-06C37.2548 2.18624e-06 48 10.7452 48 24C48 37.2548 37.2548 48 24 48Z" fill="#F2F4F6"/>
<path d="M15.6629 18.3417C15.6629 15.7188 17.7892 13.5925 20.4121 13.5925C22.7737 13.5925 24.7326 15.3162 25.0995 17.5742C25.0466 17.5713 24.9934 17.5698 24.9398 17.5698C23.3538 17.5698 22.0682 18.8554 22.0682 20.4414C22.0682 22.0273 23.3538 23.3129 24.9398 23.3129C25.0106 23.3129 25.0808 23.3104 25.1503 23.3053C24.9833 25.7766 22.9257 27.7295 20.4121 27.7295C17.7892 27.7295 15.6629 25.6032 15.6629 22.9804V18.3417Z" fill="#737A87"/>
<path d="M24.9378 21.5468C25.5478 21.5468 26.0423 21.0523 26.0423 20.4423C26.0423 19.8324 25.5478 19.3379 24.9378 19.3379C24.3278 19.3379 23.8334 19.8324 23.8334 20.4423C23.8334 21.0523 24.3278 21.5468 24.9378 21.5468Z" fill="#737A87"/>
<path d="M28.1091 17.8823C28.5462 17.6655 29.0764 17.844 29.2933 18.2811C29.6237 18.9471 29.7995 19.6854 29.7996 20.4402C29.7998 21.2991 29.5724 22.1366 29.1486 22.8707C28.9046 23.2933 28.3643 23.4381 27.9417 23.1942C27.5191 22.9502 27.3742 22.4098 27.6182 21.9872C27.8879 21.52 28.0326 20.987 28.0325 20.4405C28.0324 19.9602 27.9206 19.4904 27.7103 19.0665C27.4934 18.6294 27.6719 18.0992 28.1091 17.8823Z" fill="#737A87"/>
<path d="M32.6708 16.1604C32.4346 15.7335 31.8969 15.5788 31.47 15.8151C31.043 16.0513 30.8884 16.589 31.1246 17.0159C31.7027 18.0607 32.0096 19.2405 32.0082 20.4462C32.0068 21.6897 31.6774 22.9049 31.0612 23.9723C30.8172 24.3949 30.962 24.9352 31.3846 25.1792C31.8072 25.4232 32.3476 25.2784 32.5916 24.8558C33.3618 23.5217 33.7735 22.0027 33.7753 20.4482C33.7771 18.9411 33.3934 17.4664 32.6708 16.1604Z" fill="#737A87"/>
<path d="M13.973 27.4434C14.3743 27.1657 14.9247 27.2658 15.2024 27.667C16.3345 29.3025 18.2433 30.3788 20.4123 30.3788C22.5813 30.3788 24.4901 29.3025 25.6222 27.667C25.8999 27.2658 26.4503 27.1657 26.8515 27.4434C27.2528 27.7212 27.3529 28.2716 27.0751 28.6728C25.7998 30.5153 23.7592 31.8002 21.4077 32.0859V33.0316C21.4077 33.5195 21.0121 33.9151 20.5241 33.9151C20.0361 33.9151 19.6406 33.5195 19.6406 33.0316V32.11C17.1951 31.8808 15.0651 30.5735 13.7494 28.6728C13.4717 28.2716 13.5718 27.7212 13.973 27.4434Z" fill="#737A87"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,5 +0,0 @@
<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.4" fill-rule="evenodd" clip-rule="evenodd" d="M13.0418 1.56292C13.1724 1.05839 13.8882 1.05669 14.0212 1.56059L14.1533 2.06099C14.3392 2.76566 14.8896 3.316 15.5943 3.50196L16.0946 3.63402C16.5985 3.767 16.5968 4.48284 16.0923 4.61342L15.6006 4.74069C14.8917 4.92416 14.3372 5.4761 14.1503 6.18409L14.0212 6.67326C13.8882 7.17716 13.1724 7.17546 13.0418 6.67093L12.9175 6.19049C12.7331 5.47828 12.177 4.92211 11.4648 4.73777L10.9843 4.61342C10.4798 4.48284 10.4781 3.767 10.982 3.63402L11.4711 3.50493C12.1791 3.31808 12.7311 2.76352 12.9146 2.05464L13.0418 1.56292Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.33601 6.83179C5.56453 5.94887 6.81725 5.94589 7.04996 6.82772L7.28106 7.70342C7.6065 8.93658 8.56959 9.89968 9.80276 10.2251L10.6785 10.4562C11.5603 10.6889 11.5573 11.9416 10.6744 12.1702L9.81387 12.3929C8.57334 12.714 7.60285 13.6799 7.27587 14.9188L7.04996 15.7749C6.81725 16.6567 5.56453 16.6537 5.33601 15.7708L5.1184 14.93C4.79581 13.6837 3.82251 12.7104 2.57615 12.3878L1.73535 12.1702C0.852431 11.9416 0.849454 10.6889 1.73128 10.4562L2.58733 10.2303C3.82632 9.90333 4.79221 8.93284 5.11329 7.69231L5.33601 6.83179Z" fill="white"/>
<path d="M11.7303 30.5828C13.4822 30.5828 15.7439 29.4157 17.3328 28.4364C19.4607 27.1252 21.753 25.3112 23.7877 23.3288L23.795 23.3215L24.441 22.6753C26.4235 20.6408 28.2373 18.3483 29.5487 16.2205C30.5279 14.6315 31.6951 12.3698 31.6951 10.6179C31.6951 8.7432 30.3545 6.75336 29.7781 5.98492C29.3283 5.38525 27.7571 3.42847 26.422 3.42847C25.8737 3.42847 25.2834 3.78728 24.5074 4.59199C23.8211 5.30352 23.1626 6.18794 22.7311 6.80464C22.1514 7.63258 21.6154 8.49942 21.2215 9.24569C20.5838 10.4539 20.51 10.972 20.51 11.2566C20.51 11.831 20.8101 12.3293 21.4017 12.7374C21.7912 13.0061 22.2588 13.2084 22.711 13.4041C23.016 13.536 23.503 13.7465 23.6888 13.8946C23.6239 14.1691 23.3407 14.832 22.5752 15.9559C21.8618 17.0032 20.9394 18.1381 20.1061 18.9939C19.2504 19.8271 18.1156 20.7496 17.0681 21.463C15.9445 22.2282 15.2816 22.5116 15.0069 22.5765C14.859 22.3907 14.6483 21.9038 14.5163 21.5988C14.3208 21.1466 14.1185 20.6789 13.8497 20.2894C13.4416 19.6978 12.9434 19.3979 12.3689 19.3979C12.0843 19.3979 11.5662 19.4716 10.3578 20.1092C9.61174 20.5031 8.74477 21.0391 7.91693 21.6187C7.30026 22.0504 6.41583 22.709 5.70415 23.3951C4.8996 24.171 4.54078 24.7614 4.54078 25.3097C4.54078 26.6447 6.49741 28.216 7.09711 28.6658C7.86567 29.2422 9.85549 30.5828 11.7303 30.5828Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

View File

@ -1,19 +0,0 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="36" width="36" height="36" rx="4" transform="rotate(-90 0 36)" fill="black" fill-opacity="0.4"/>
<g filter="url(#filter0_d_100_31232)">
<rect x="9" y="9" width="18" height="18" rx="3" stroke="white" stroke-width="2"/>
<path d="M19 9H25C26.1046 9 27 9.89543 27 11V17H21C19.8954 17 19 16.1046 19 15V9Z" fill="white" fill-opacity="0.4" stroke="white" stroke-width="2"/>
</g>
<defs>
<filter id="filter0_d_100_31232" x="5" y="5" width="26" height="26" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_100_31232"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_100_31232" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,26 +0,0 @@
.container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.avatarContainer {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.avatarSvg {
max-width: 80%;
height: auto;
}
/* 加载文字样式 */
.loadingText {
margin-top: 30px;
font-size: 18px;
text-align: center;
}

View File

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

View File

@ -1,100 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.card {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
text-align: center;
text-align: center;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
.avatar {
position: relative;
border-radius: 50%;
width: 167.5px;
height: 167.5px;
img {
width: 100%;
height: 100%;
}
}
.aiStatus {
position: absolute;
border: 1px solid;
border-image-source: linear-gradient(77.86deg, #e5f2ff -3.23%, #d9e5ff 51.11%, #f6e2ff 98.65%);
box-shadow: 0px 2px 22px 0px #0000001a;
width: 93px;
height: 73px;
border-radius: 24px;
top: -28px;
left: -56px;
color: #635bff;
font-weight: 500;
font-size: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 8px;
background: #ffffff;
}
.barContainer {
display: flex;
gap: 4px;
}
.bar {
width: 11px;
height: 16px;
border-radius: 6px;
animation: shake 1s ease infinite;
background-color: #4f4fff;
}
.bar:nth-child(1) {
animation-delay: -0.4s;
}
.bar:nth-child(2) {
animation-delay: -0.2s;
}
@keyframes shake {
0% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.5);
}
100% {
transform: scaleY(1);
}
}
}
.fullScreen {
.avatar {
width: 115px;
height: 115px;
}
.aiStatus {
width: 72px;
height: 56px;
top: -24px;
left: 86px;
font-size: 12px;
}
}

View File

@ -1,52 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useSelector } from 'react-redux';
import { RootState } from '@/store';
import UserTag from '../UserTag';
import { useDeviceState, useScene } from '@/lib/useCommon';
import style from './index.module.less';
interface IAiAvatarCardProps {
showStatus: boolean;
showUserTag: boolean;
className?: string;
}
const THRESHOLD_VOLUME = 18;
function AiAvatarCard(props: IAiAvatarCardProps) {
const { showStatus, showUserTag, className } = props;
const room = useSelector((state: RootState) => state.room);
const { icon } = useScene();
const { scene, isAITalking, isFullScreen } = room;
const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0;
const { isAudioPublished } = useDeviceState();
const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished;
return (
<div className={`${style.card} ${className} ${isFullScreen ? style.fullScreen : ''}`}>
<div className={style.avatar}>
<img id="avatar-card" src={icon} alt="Avatar" />
{showStatus ? (
isAITalking ? (
<div className={style.aiStatus}>
<div className={style.barContainer}>
<div className={style.bar} />
<div className={style.bar} />
<div className={style.bar} />
</div>
</div>
) : isLoading ? (
<div className={style.aiStatus}>...</div>
) : null
) : null}
</div>
{showUserTag ? <UserTag name={scene} /> : null}
</div>
);
}
export default AiAvatarCard;

View File

@ -1,84 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
position: relative;
width: max-content;
height: 50px;
border-radius: 100px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: #737a87;
font-size: 14px;
line-height: 22px;
border: 1px solid#DDE2E9;
margin-bottom: 16px;
.content {
width: 100%;
height: 100%;
padding: 12px;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
gap: 4px;
.icon {
border-radius: 50%;
width: 26px;
height: 26px;
}
.checked-text {
width: max-content;
font-size: 13px;
line-height: 22px;
}
}
}
.wrapper:hover {
box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15);
}
.active {
border: 1px solid transparent;
background: linear-gradient(77.86deg, #f1f9ff -3.23%, #edf3ff 51.11%, #faf4ff 98.65%) padding-box,
linear-gradient(77.86deg, #3b91ff -3.23%, #0d5eff 51.11%, #c069ff 98.65%) border-box;
.content {
.checked-text {
background: linear-gradient(90deg, #004fff 38.86%, #9865ff 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 13px;
font-weight: 500;
line-height: 22px;
}
}
}
.active:hover {
box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15);
}
.tag {
position: absolute;
top: 0;
right: 0;
z-index: 3;
font-size: 10px;
font-weight: 500;
line-height: 18px;
transform: translate(20%, -50%);
background: rgba(134, 123, 227, 1);
padding: 0px 6px 0px 6px;
border-radius: 20px 20px 20px 0px;
color: white;
}

View File

@ -1,29 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import styles from './index.module.less';
interface IProps {
checked: boolean;
title?: string;
onClick?: () => void;
icon?: string;
tag?: string;
}
function CheckScene(props: IProps) {
const { tag, icon, title, checked, onClick } = props;
return (
<div className={`${styles.wrapper} ${checked ? styles.active : ''}`} onClick={onClick}>
{tag ? <div className={styles.tag}>{tag}</div> : ''}
<div className={styles.content}>
{icon ? <img className={styles.icon} src={icon} alt="icon" /> : ''}
<div className={styles['checked-text']}>{title}</div>
</div>
</div>
);
}
export default CheckScene;

View File

@ -1,64 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.card {
position: relative;
text-align: center;
text-align: center;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
.avatar {
img {
width: 128px;
height: 128px;
}
border-radius: 50%;
width: 128px;
height: 128px;
background: linear-gradient(180deg, #c3e4ff 0%, #98d6fe 100%);
}
.title {
font-weight: 500;
font-size: 24px;
line-height: 32px;
color: #1d2129;
}
.desc {
margin-top: 8px;
font-weight: 400;
font-size: 14px;
line-height: 22px;
color: #737a87;
}
.exceededTitle {
font-weight: 500;
font-size: 24px;
line-height: 32px;
background: linear-gradient(77.86deg, #3b91ff -3.23%, #0d5eff 51.11%, #c069ff 98.65%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
cursor: pointer;
img {
margin-left: 4px;
}
}
.sceneContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
width: max-content;
}
}

View File

@ -1,50 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/store';
import CheckScene from './CheckScene';
import { SceneConfig, updateScene } from '@/store/slices/room';
import { useScene } from '@/lib/useCommon';
import style from './index.module.less';
function AIChangeCard() {
const { scene, sceneConfigMap } = useSelector((state: RootState) => state.room);
const dispatch = useDispatch();
const { icon, isVision } = useScene();
const Scenes = Object.keys(sceneConfigMap).map(key => sceneConfigMap[key]);
const handleChecked = (checkedScene: string) => {
dispatch(updateScene(checkedScene));
};
return (
<div className={style.card}>
<div className={style.avatar}>
<img id="avatar-card" src={icon} alt="Avatar" />
</div>
<div className={style.title}>
<div>Hi AI</div>
<div className={style.desc}>
{isVision ? <> Vision </> : ''}
</div>
</div>
<div className={style.sceneContainer}>
{Scenes.map((key: SceneConfig) =>
<CheckScene
key={key.name}
icon={key.icon}
title={key.name}
checked={key.id === scene}
onClick={() => handleChecked(key.id)}
/>
)}
</div>
</div>
);
}
export default AIChangeCard;

View File

@ -1,84 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.row {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
.firstPart {
display: flex;
flex-direction: row;
align-items: center;
width: 90%;
color: var(--text-color-text-2, var(--text-color-text-2, #42464E));
text-align: center;
/* Body/body-2 medium */
font-family: "PingFang SC";
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 22px; /* 169.231% */
letter-spacing: 0.039px;
}
.finalPart {
display: flex;
flex-direction: row;
align-items: center;
width: 10%;
justify-content: flex-end;
.rightOutlined {
font-size: 12px;
}
}
.icon {
margin-right: 4px;
}
}
.footer {
width: calc(100% - 12px);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
.cancel {
width: 88px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--line-color-border-3, rgba(221, 226, 233, 1));
margin-right: 12px;
}
.confirm {
width: 88px;
height: 32px;
border-radius: 6px;
background: linear-gradient(95.87deg, #1664FF 0%, #8040FF 97.7%);
color: white;
}
}
.children {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
:global {
.ant-drawer-body {
padding: 12px 24px 0px 24px;
}
}

View File

@ -1,72 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import React, { useState } from 'react';
import { Drawer, DrawerProps } from '@arco-design/web-react';
import { IconRight } from '@arco-design/web-react/icon';
import styles from './index.module.less';
type IDrawerRowItemProps = {
btnSrc?: string;
btnText: string;
suffix?: React.ReactNode;
drawer?: {
title: string;
width?: string | number;
onOpen?: () => void;
onClose?: () => void;
onCancel?: () => void;
onConfirm?: (handleClose: () => void) => void;
children?: React.ReactNode;
footer?: React.ReactNode | boolean;
} & DrawerProps;
} & React.HTMLAttributes<HTMLDivElement>;
function DrawerRowItem(props: IDrawerRowItemProps) {
const { btnSrc, btnText, suffix, drawer, style, className = '' } = props;
const [open, setOpen] = useState(false);
const { onClose, onOpen } = drawer!;
const handleClose = () => {
drawer?.onCancel?.();
setOpen(false);
onClose?.();
};
const handleOpen = () => {
setOpen(true);
if (drawer) {
onOpen?.();
}
};
return (
<>
<div style={style || {}} className={`${styles.row} ${className}`} onClick={handleOpen}>
<div className={styles.firstPart}>
{btnSrc ? <img src={btnSrc} className={styles.icon} alt="svg" /> : ''}
{btnText}
{suffix}
</div>
<div className={styles.finalPart}>
<IconRight className={styles.rightOutlined} />
</div>
</div>
<Drawer
closable
title={drawer?.title || ''}
width={drawer?.width || 400}
className={styles.drawer}
visible={open}
onCancel={handleClose}
footer={null}
>
<div className={styles.children}>{drawer?.children}</div>
</Drawer>
</>
);
}
export default DrawerRowItem;

View File

@ -1,37 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.card {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
text-align: center;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
.tag {
position: absolute;
left: 16px;
top: 16px;
}
&.hidden {
display: none;
}
}
.blur-bg {
background-position: center;
background-size: cover;
background-repeat: no-repeat;
filter: blur(20px);
transform: scale(1.1);
}

View File

@ -1,39 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useSelector } from 'react-redux';
import UserTag from '../UserTag';
import { RootState } from '@/store';
import style from './index.module.less';
import { useScene } from '@/lib/useCommon';
import { isMobile } from '@/utils/utils';
export const LocalFullID = 'local-full-player';
export const RemoteFullID = 'remote-full-player';
function FullScreenCard() {
const isFullScreen = useSelector((state: RootState) => state.room.isFullScreen);
const scene = useScene();
return (
<>
<div className={`${style.card} ${!isFullScreen ? style.hidden : ''}`} id={LocalFullID}>
<UserTag name="我" className={style.tag} />
</div>
<div
className={`${style.card} ${isFullScreen ? style.hidden : ''} ${style['blur-bg']}`}
style={{ backgroundImage: `url(${scene.avatarBgUrl})` }}
/>
<div
className={`${style.card} ${isFullScreen ? style.hidden : ''}`}
style={{ background: 'unset' }}
>
<div id={RemoteFullID} style={{ width: '60%', height: '100%' }} />
{!isMobile() ? <UserTag name="AI" className={style.tag} /> : null}
</div>
</>
);
}
export default FullScreenCard;

View File

@ -1,129 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.header {
height: 48px;
background: white;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
:global {
.arco-popover-content-top {
padding: 0px;
}
}
}
.header-logo {
display: flex;
align-items: center;
justify-content: space-between;
margin-left: 24px;
:global {
img {
height: 24px;
}
.arco-popover-content {
padding: 0;
}
}
}
.menu-wrapper {
display: flex;
flex-direction: column;
align-items: center;
row-gap: 8px;
justify-content: space-between;
}
.header-logo-text {
background: linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 16px;
}
.header-right {
z-index: 2;
color: #fff;
display: flex;
align-items: center;
:global {
span {
height: 24px;
}
}
}
.header-setting-btn {
background-color: transparent;
border: none;
margin-right: 24px;
color: #000000;
font-size: 16px;
cursor: pointer;
}
.header-pop {
:global {
.ant-popover-arrow {
left: 16px;
.ant-popover-arrow-content {
&:before {
background-color: white;
}
}
}
.ant-popover-content {
margin-left: 12px;
}
.ant-popover-inner {
margin-right: 12px;
}
.ant-popover-inner-content {
padding: 0;
background-color: white;
position: relative;
width: 100px;
height: 100px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-between;
padding-bottom: 7px;
padding-top: 7px;
cursor: pointer;
color: black;
div {
font-size: 13px;
font-weight: 400;
line-height: 20px;
&:hover {
color: #1664ff;
}
}
}
}
}
.divider {
margin-top: 2px;
margin-bottom: 2px;
min-width: 70%;
width: 70%;
}
.header-right-text {
color: #000000;
margin-right: 24px;
cursor: pointer;
}

View File

@ -1,102 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { Button, Divider, Popover } from '@arco-design/web-react';
import { IconMenu } from '@arco-design/web-react/icon';
import NetworkIndicator from '@/components/NetworkIndicator';
import { useIsMobile } from '@/utils/utils';
import Logo from '@/assets/img/Logo.svg';
import styles from './index.module.less';
const Disclaimer = 'https://www.volcengine.com/docs/6348/68916';
const ReversoContext = 'https://www.volcengine.com/docs/6348/68918';
const UserAgreement = 'https://www.volcengine.com/docs/6348/128955';
interface HeaderProps {
children?: React.ReactNode;
hide?: boolean;
}
function Header(props: HeaderProps) {
const { children, hide } = props;
const MenuProps = [
{
name: '免责声明',
url: Disclaimer,
},
{
name: '隐私政策',
url: ReversoContext,
},
{
name: '用户协议',
url: UserAgreement,
},
];
return (
<div
className={styles.header}
style={{
display: hide ? 'none' : 'flex',
}}
>
<div className={styles['header-logo']}>
{useIsMobile() ? null : (
<Popover
content={
<div className={styles['menu-wrapper']}>
{MenuProps.map((menuItem) => (
<Button
type="text"
key={menuItem.name}
onClick={() => {
window.open(menuItem.url, '_blank');
}}
>
{menuItem.name}
</Button>
))}
</div>
}
>
<IconMenu className={styles['header-setting-btn']} />
</Popover>
)}
<img src={Logo} alt="Logo" />
<Divider type="vertical" />
<span className={styles['header-logo-text']}> AI </span>
<NetworkIndicator />
</div>
{children}
{useIsMobile() ? null : (
<div className={styles['header-right']}>
<div
className={styles['header-right-text']}
onClick={() =>
window.open('https://www.volcengine.com/product/veRTC/ConversationalAI', '_blank')
}
>
</div>
<div
className={styles['header-right-text']}
onClick={() =>
window.open(
'https://www.volcengine.com/contact/product?t=%E5%AF%B9%E8%AF%9D%E5%BC%8Fai&source=%E4%BA%A7%E5%93%81%E5%92%A8%E8%AF%A2',
'_blank'
)
}
>
</div>
</div>
)}
</div>
);
}
export default Header;

View File

@ -1,37 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.loader {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 6px;
height: 36px;
margin-top: 4px;
}
.dot {
width: 20px;
height: 20px;
border-radius: 12px;
background-color: rgba(148, 116, 255, 1);
}
.dotter {
animation: glow 0.9s infinite;
}
@keyframes glow {
0% {
height: 20px;
}
50% {
height: 36px;
}
100% {
height: 20px;
}
}

View File

@ -1,33 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { memo } from 'react';
import style from './index.module.less';
interface IAudioLoadingProps extends React.HTMLAttributes<HTMLDivElement> {
loading?: boolean;
}
function AudioLoading(props: IAudioLoadingProps) {
const { loading = false, className = '', color, ...rest } = props;
return (
<div className={`${style.loader} ${className}`} {...rest}>
{Array(3)
.fill(0)
.map((_, index) => (
<div
key={index}
className={`${style.dot} ${loading ? style.dotter : ''}`}
style={{
animationDelay: `${index * 0.3}s`,
backgroundColor: color || 'rgba(148, 116, 255, 1)',
}}
/>
))}
</div>
);
}
export default memo(AudioLoading);

View File

@ -1,16 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.loader {
display: flex;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: white;
animation: glow 0.9s infinite;
}

View File

@ -1,41 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { memo } from 'react';
import style from './index.module.less';
interface ILoadingProps extends React.HTMLAttributes<HTMLDivElement> {
dotClassName?: string;
speed?: number;
gap?: number;
}
function Loading(props: ILoadingProps) {
const { dotClassName, gap = 5, speed = 0.9, className = '', ...rest } = props;
return (
<div
className={`${style.loader} ${className}`}
style={{
gap: `${gap}px`,
}}
{...rest}
>
{Array(3)
.fill(0)
.map((_, index) => (
<div
key={index}
className={`${style.dot} ${dotClassName}`}
style={{
animation: `glow linear ${speed.toFixed(1)}s infinite`,
animationDelay: `${(index * (speed / 3)).toFixed(1)}s`,
}}
/>
))}
</div>
);
}
export default memo(Loading);

View File

@ -1,48 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.loader {
width: 40px;
height: 10px;
display: flex;
justify-content: center;
align-items: center;
margin: 6px 0px;
}
.bar {
width: 3px;
height: 12px;
margin: 1px;
display: inline-block;
animation: shake 0.6s ease infinite;
}
/* 为每个 bar 指定不同的延迟来实现抖动效果 */
.bar:nth-child(1) {
animation-delay: -0.2s;
}
.bar:nth-child(2) {
animation-delay: -0.1s;
}
.bar:nth-child(3) {
}
@keyframes shake {
0% {
transform: scaleY(1);
background-color: var(--primary-color-primary-7, rgba(23, 89, 221, 1));
}
50% {
transform: scaleY(0.5);
background-color: var(--primary-color-primary-3, rgba(151, 188, 255, 1));
}
100% {
transform: scaleY(1);
background-color: var(--primary-color-primary-7, rgba(23, 89, 221, 1));
}
}

View File

@ -1,19 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { memo } from 'react';
import styles from './index.module.less';
function Loading() {
return (
<span className={styles.loader}>
<span className={styles.bar} />
<span className={styles.bar} />
<span className={styles.bar} />
</span>
);
}
export default memo(Loading);

View File

@ -1,20 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.container {
position: absolute;
right: 0;
top: 148px;
width: 36px;
height: 36px;
border-radius: 4px;
cursor: pointer;
z-index: 1;
img {
width: 100%;
height: 100%;
}
}

View File

@ -1,40 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Popover } from '@arco-design/web-react';
import { RootState } from '@/store';
import { updateFullScreen } from '@/store/slices/room';
import SET_LOCAL_PLAYER from '@/assets/img/setLocalPlayer.svg';
import styles from './index.module.less';
function LocalPlayerSet() {
const dispatch = useDispatch();
const room = useSelector((state: RootState) => state.room);
const { isFullScreen } = room;
const [loading, setLoading] = useState(false);
const [isFull, setFull] = useState(isFullScreen);
const setLocalPlayer = () => {
setLoading(true);
setFull(!isFull);
dispatch(updateFullScreen({ isFullScreen: !isFull }));
setLoading(false);
};
return (
<div
onClick={setLocalPlayer}
className={styles.container}
style={{ cursor: loading ? 'not-allowed' : 'pointer' }}
>
<Popover content="切换屏幕">
<img src={SET_LOCAL_PLAYER} alt="fullSize" />
</Popover>
</div>
);
}
export default LocalPlayerSet;

View File

@ -1,60 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.panel {
display: flex;
.label {
width: 90px;
display: flex;
flex-direction: column;
gap: 4px;
.state {
font-weight: bold;
}
}
.value {
display: flex;
flex-direction: column;
gap: 4px;
width: max-content;
.state {
font-weight: bold;
}
.loss {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 12px;
}
}
}
.wrapper {
display: flex;
align-items: flex-end;
height: 14px;
width: 14px;
margin: 14px;
column-gap: 1.5px;
background-color: rgba(142, 142, 142, 0.05);
border-radius: 3px;
padding: 2px;
.indicator {
width: 30%;
border-color: rgba(127, 127, 127, 0.184);
border-width: 1px;
border-radius: 1px;
border-style: solid;
opacity: 0.8;
transition: height 0.3s;
box-sizing: border-box;
}
}

View File

@ -1,112 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useMemo } from 'react';
import { Popover } from '@arco-design/web-react';
import { useSelector } from 'react-redux';
import { IconArrowDown, IconArrowUp } from '@arco-design/web-react/icon';
import { NetworkQuality } from '@volcengine/rtc';
import { RootState } from '@/store';
import { useScene } from '@/lib/useCommon';
import style from './index.module.less';
enum INDICATOR_COLORS {
GREAT = 'rgba(35, 195, 67, 1)',
FAIR = 'rgba(208, 141, 6, 1)',
BAD = 'rgba(245, 78, 78, 1)',
PLACE_HOLDER = 'transparent',
}
const INDICATOR_TEXT = {
[NetworkQuality.UNKNOWN]: '正常',
[NetworkQuality.EXCELLENT]: '正常',
[NetworkQuality.GOOD]: '正常',
[NetworkQuality.POOR]: '一般',
[NetworkQuality.BAD]: '一般',
[NetworkQuality.VBAD]: '较差',
[NetworkQuality.DOWN]: '较差',
};
function NetworkIndicator() {
const room = useSelector((state: RootState) => state.room);
const { botName } = useScene();
const networkQuality = room.networkQuality;
const delay = room.localUser.audioStats?.rtt;
const audioLossRateUpper = room.localUser.audioStats?.audioLossRate || 0;
const audioLossRateLower =
room.remoteUsers.find((user) => user.userId === botName)?.audioStats
?.audioLossRate || 0;
const indicators = useMemo(() => {
switch (networkQuality) {
case NetworkQuality.UNKNOWN:
case NetworkQuality.EXCELLENT:
case NetworkQuality.GOOD:
return Array(3).fill(INDICATOR_COLORS.GREAT);
case NetworkQuality.POOR:
case NetworkQuality.BAD:
return Array(2).fill(INDICATOR_COLORS.FAIR).concat(INDICATOR_COLORS.PLACE_HOLDER);
case NetworkQuality.VBAD:
case NetworkQuality.DOWN:
default:
return [INDICATOR_COLORS.BAD].concat(...Array(2).fill(INDICATOR_COLORS.PLACE_HOLDER));
}
}, [networkQuality]);
return (
<Popover
position="bl"
content={
<div className={style.panel}>
<div className={style.label}>
<div className={style.state}></div>
<div className={style.item}></div>
<div className={style.item}></div>
</div>
<div className={style.value}>
<div
className={style.state}
style={{
color: indicators?.[0] || INDICATOR_COLORS.BAD,
}}
>
{INDICATOR_TEXT[networkQuality]}
</div>
<div className={style.item}>{delay ? delay.toFixed(0) : '- '}ms</div>
<div className={style.loss}>
<div>
<IconArrowUp style={{ color: 'rgba(22, 100, 255, 1)' }} />
<span>
{`${audioLossRateUpper}` ? (audioLossRateUpper * 100)?.toFixed(0) : '- '}%
</span>
</div>
<div>
<IconArrowDown />
<span>
{`${audioLossRateLower}` ? (audioLossRateLower * 100)?.toFixed(0) : '- '}%
</span>
</div>
</div>
</div>
</div>
}
>
<div className={style.wrapper}>
{indicators.map((color, index) => (
<div
key={index}
className={style.indicator}
style={{
height: `${20 + (80 * (index + 1)) / 3}%`,
backgroundColor: color,
}}
/>
))}
</div>
</Popover>
);
}
export default NetworkIndicator;

View File

@ -1,8 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.container {
position: relative;
}

View File

@ -1,37 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useEffect, useRef } from 'react';
import styles from './index.module.less';
export type IWrapperProps = React.PropsWithChildren & {
className?: string;
};
export default function (props: IWrapperProps) {
const { children, className = '' } = props;
const ref = useRef<HTMLDivElement>(null);
const resize = () => {
if (ref.current) {
ref.current.style.height = `${window.innerHeight}px`;
}
};
useEffect(() => {
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, []);
return (
<div className={`${styles.container} ${className}`} ref={ref}>
{children}
</div>
);
}

View File

@ -1,34 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.userTagWrapper {
display: flex;
border-radius: 6px;
border: 0.4px solid #1f232926;
background-color: #fff;
width: max-content;
z-index: 1;
margin-bottom: 45px;
}
.iconContainer {
background-color: #5a6169;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.nameContainer {
color: #0c0d0e;
padding: 0 4px;
height: 20px;
line-height: 20px;
font-size: 12px;
font-weight: 500;
}

View File

@ -1,26 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { IconUser } from '@arco-design/web-react/icon';
import styles from './index.module.less';
interface IUserTagProps {
name: string;
className?: string;
}
function UserTag(props: IUserTagProps) {
const { name, className } = props;
return (
<div className={`${styles.userTagWrapper} ${className}`}>
<div className={styles.iconContainer}>
<IconUser style={{ fill: '#fff', strokeWidth: 0 }} />
</div>
<div className={styles.nameContainer}>{name}</div>
</div>
);
}
export default UserTag;

View File

@ -1,24 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
export const Disclaimer = 'https://www.volcengine.com/docs/6348/68916';
export const ReversoContext = 'https://www.volcengine.com/docs/6348/68918';
export const UserAgreement = 'https://www.volcengine.com/docs/6348/128955';
/**
* @note API Proxy Server( Demo Node server)
* 访
*/
export const AIGC_PROXY_HOST = 'http://localhost:3001';
export interface IScene {
icon: string;
name: string;
questions: string[];
agentConfig: Record<string, any>;
llmConfig: Record<string, any>;
asrConfig: Record<string, any>;
ttsConfig: Record<string, any>;
}

View File

@ -1,43 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
@import './theme.less';
body {
margin: 0;
overflow: hidden;
width: 100% !important;
background: linear-gradient(
109.22deg,
rgba(116, 37, 255, 0.05) 0.27%,
rgba(39, 88, 255, 0.05) 51.39%,
rgba(0, 102, 255, 0.05) 99.54%
);
img {
user-drag: none;
-webkit-user-drag: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
a {
text-decoration: none;
}
}
@keyframes glow {
0% {
opacity: 1;
}
40% {
opacity: 0.7;
}
100% {
opacity: 0.3;
}
}

View File

@ -1,42 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
#demo-for-xxx-provider {
flex: 1;
// ------背景色------
// 页面可以配置背景色,但不建议,设计同学建议将背景色设置成透明,透出主应用的渐变背景色
background: transparent;
// -----------------
// ------适配------
width: 100%;
height: 100%;
min-width: 730px; // 最小宽度可根据情况自定义页面显示区域不够最小高度时会允许scroll。
// 建议pc端最小宽度小于等于730px渲染区域的最小尺寸这样可以避免页面滚动用户体验更好。
min-height: 1000px; // 最小高度可根据情况自定义页面显示区域不够最小高度时会允许scroll。
// 官网规范,<768px时为移动端
@media (max-width: 767px) {
width: 100%; // 移动端渲染区域的宽度,等于设备屏幕的宽度
}
// -----------------
// 写全局样式要防止与官网样式冲突
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
}
.container-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
}

View File

@ -1,17 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import store from './store';
import './index.less';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<Provider store={store}>
<App />
</Provider>
);

View File

@ -1,9 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
export enum DeviceType {
Camera = 'camera',
Microphone = 'microphone',
}

View File

@ -1,436 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import VERTC, {
MirrorType,
StreamIndex,
IRTCEngine,
RoomProfileType,
onUserJoinedEvent,
onUserLeaveEvent,
MediaType,
LocalStreamStats,
RemoteStreamStats,
StreamRemoveReason,
LocalAudioPropertiesInfo,
RemoteAudioPropertiesInfo,
AudioProfileType,
DeviceInfo,
AutoPlayFailedEvent,
PlayerEvent,
NetworkQuality,
VideoRenderMode,
ScreenEncoderConfig,
} from '@volcengine/rtc';
import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr';
import { Message } from '@arco-design/web-react';
import Apis from '@/app/index';
import { string2tlv } from '@/utils/utils';
import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler';
export interface IEventListener {
handleError: (e: { errorCode: any }) => void;
handleUserJoin: (e: onUserJoinedEvent) => void;
handleUserLeave: (e: onUserLeaveEvent) => void;
handleTrackEnded: (e: { kind: string; isScreen: boolean }) => void;
handleUserPublishStream: (e: { userId: string; mediaType: MediaType }) => void;
handleUserUnpublishStream: (e: {
userId: string;
mediaType: MediaType;
reason: StreamRemoveReason;
}) => void;
handleRemoteStreamStats: (e: RemoteStreamStats) => void;
handleLocalStreamStats: (e: LocalStreamStats) => void;
handleLocalAudioPropertiesReport: (e: LocalAudioPropertiesInfo[]) => void;
handleRemoteAudioPropertiesReport: (e: RemoteAudioPropertiesInfo[]) => void;
handleAudioDeviceStateChanged: (e: DeviceInfo) => void;
handleAutoPlayFail: (e: AutoPlayFailedEvent) => void;
handlePlayerEvent: (e: PlayerEvent) => void;
handleRoomBinaryMessageReceived: (e: { userId: string; message: ArrayBuffer }) => void;
handleNetworkQuality: (
uplinkNetworkQuality: NetworkQuality,
downlinkNetworkQuality: NetworkQuality
) => void;
}
export interface BasicBody {
app_id: string;
room_id: string;
user_id: string;
token?: string;
}
/**
* @brief RTC Core Client
* @notes Refer to official website documentation to get more information about the API.
*/
export class RTCClient {
engine!: IRTCEngine;
basicInfo!: BasicBody;
private _audioCaptureDevice?: string;
private _videoCaptureDevice?: string;
audioBotEnabled = false;
audioBotStartTime = 0;
createEngine = async () => {
this.engine = VERTC.createEngine(this.basicInfo.app_id);
try {
const AIAnsExtension = new RTCAIAnsExtension();
await this.engine.registerExtension(AIAnsExtension);
AIAnsExtension.enable();
} catch (error) {
console.warn(
`当前环境不支持 AI 降噪, 此错误可忽略, 不影响实际使用, e: ${(error as any).message}`
);
}
};
addEventListeners = ({
handleError,
handleUserJoin,
handleUserLeave,
handleTrackEnded,
handleUserPublishStream,
handleUserUnpublishStream,
handleRemoteStreamStats,
handleLocalStreamStats,
handleLocalAudioPropertiesReport,
handleRemoteAudioPropertiesReport,
handleAudioDeviceStateChanged,
handleAutoPlayFail,
handlePlayerEvent,
handleRoomBinaryMessageReceived,
handleNetworkQuality,
}: IEventListener) => {
this.engine.on(VERTC.events.onError, handleError);
this.engine.on(VERTC.events.onUserJoined, handleUserJoin);
this.engine.on(VERTC.events.onUserLeave, handleUserLeave);
this.engine.on(VERTC.events.onTrackEnded, handleTrackEnded);
this.engine.on(VERTC.events.onUserPublishStream, handleUserPublishStream);
this.engine.on(VERTC.events.onUserUnpublishStream, handleUserUnpublishStream);
this.engine.on(VERTC.events.onRemoteStreamStats, handleRemoteStreamStats);
this.engine.on(VERTC.events.onLocalStreamStats, handleLocalStreamStats);
this.engine.on(VERTC.events.onAudioDeviceStateChanged, handleAudioDeviceStateChanged);
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, handleLocalAudioPropertiesReport);
this.engine.on(VERTC.events.onRemoteAudioPropertiesReport, handleRemoteAudioPropertiesReport);
this.engine.on(VERTC.events.onAutoplayFailed, handleAutoPlayFail);
this.engine.on(VERTC.events.onPlayerEvent, handlePlayerEvent);
this.engine.on(VERTC.events.onRoomBinaryMessageReceived, handleRoomBinaryMessageReceived);
this.engine.on(VERTC.events.onNetworkQuality, handleNetworkQuality);
};
joinRoom = () => {
console.log(' ------ userJoinRoom\n', `roomId: ${this.basicInfo.room_id}\n`, `uid: ${this.basicInfo.user_id}`);
return this.engine.joinRoom(
this.basicInfo.token!,
`${this.basicInfo.room_id!}`,
{
userId: this.basicInfo.user_id!,
extraInfo: JSON.stringify({
call_scene: 'RTC-AIGC',
user_name: this.basicInfo.user_id,
user_id: this.basicInfo.user_id,
}),
},
{
isAutoPublish: true,
isAutoSubscribeAudio: true,
roomProfileType: RoomProfileType.chat,
}
);
};
leaveRoom = () => {
this.audioBotEnabled = false;
this.engine.leaveRoom().catch();
VERTC.destroyEngine(this.engine);
this._audioCaptureDevice = undefined;
};
checkPermission(): Promise<{
video: boolean;
audio: boolean;
}> {
return VERTC.enableDevices({
video: false,
audio: true,
});
}
/**
* @brief get the devices
* @returns
*/
async getDevices(props?: { video?: boolean; audio?: boolean }): Promise<{
audioInputs: MediaDeviceInfo[];
audioOutputs: MediaDeviceInfo[];
videoInputs: MediaDeviceInfo[];
}> {
const { video = false, audio = true } = props || {};
let audioInputs: MediaDeviceInfo[] = [];
let audioOutputs: MediaDeviceInfo[] = [];
let videoInputs: MediaDeviceInfo[] = [];
const { video: hasVideoPermission, audio: hasAudioPermission } = await VERTC.enableDevices({
video,
audio,
});
if (audio) {
const inputs = await VERTC.enumerateAudioCaptureDevices();
const outputs = await VERTC.enumerateAudioPlaybackDevices();
audioInputs = inputs.filter((i) => i.deviceId && i.kind === 'audioinput');
audioOutputs = outputs.filter((i) => i.deviceId && i.kind === 'audiooutput');
this._audioCaptureDevice = audioInputs.filter((i) => i.deviceId)?.[0]?.deviceId;
if (hasAudioPermission) {
if (!audioInputs?.length) {
Message.error('无麦克风设备, 请先确认设备情况。');
}
if (!audioOutputs?.length) {
Message.error('无扬声器设备, 请先确认设备情况。');
}
} else {
Message.error('暂无麦克风设备权限, 请先确认设备权限授予情况。');
}
}
if (video) {
videoInputs = await VERTC.enumerateVideoCaptureDevices();
videoInputs = videoInputs.filter((i) => i.deviceId && i.kind === 'videoinput');
this._videoCaptureDevice = videoInputs?.[0]?.deviceId;
if (hasVideoPermission) {
if (!videoInputs?.length) {
Message.error('无摄像头设备, 请先确认设备情况。');
}
} else {
Message.error('暂无摄像头设备权限, 请先确认设备权限授予情况。');
}
}
return {
audioInputs,
audioOutputs,
videoInputs,
};
}
startVideoCapture = async (camera?: string) => {
await this.engine.startVideoCapture(camera || this._videoCaptureDevice);
};
stopVideoCapture = async () => {
this.engine.setLocalVideoMirrorType(MirrorType.MIRROR_TYPE_RENDER);
await this.engine.stopVideoCapture();
};
startScreenCapture = async (enableAudio = false) => {
await this.engine.startScreenCapture({
enableAudio,
});
};
stopScreenCapture = async () => {
await this.engine.stopScreenCapture();
};
startAudioCapture = async (mic?: string) => {
await this.engine.startAudioCapture(mic || this._audioCaptureDevice);
};
stopAudioCapture = async () => {
await this.engine.stopAudioCapture();
};
publishStream = (mediaType: MediaType) => {
this.engine.publishStream(mediaType);
};
unpublishStream = (mediaType: MediaType) => {
this.engine.unpublishStream(mediaType);
};
publishScreenStream = async (mediaType: MediaType) => {
await this.engine.publishScreen(mediaType);
};
unpublishScreenStream = async (mediaType: MediaType) => {
await this.engine.unpublishScreen(mediaType);
};
setScreenEncoderConfig = async (description: ScreenEncoderConfig) => {
await this.engine.setScreenEncoderConfig(description);
};
/**
* @brief
* @param businessId
*/
setBusinessId = (businessId: string) => {
this.engine.setBusinessId(businessId);
};
setAudioVolume = (volume: number) => {
this.engine.setCaptureVolume(StreamIndex.STREAM_INDEX_MAIN, volume);
this.engine.setCaptureVolume(StreamIndex.STREAM_INDEX_SCREEN, volume);
};
/**
* @brief
*/
setAudioProfile = (profile: AudioProfileType) => {
this.engine.setAudioProfile(profile);
};
/**
* @brief
*/
switchDevice = (deviceType: MediaType, deviceId: string) => {
if (deviceType === MediaType.AUDIO) {
this._audioCaptureDevice = deviceId;
this.engine.setAudioCaptureDevice(deviceId);
}
if (deviceType === MediaType.VIDEO) {
this._videoCaptureDevice = deviceId;
this.engine.setVideoCaptureDevice(deviceId);
}
if (deviceType === MediaType.AUDIO_AND_VIDEO) {
this._audioCaptureDevice = deviceId;
this._videoCaptureDevice = deviceId;
this.engine.setVideoCaptureDevice(deviceId);
this.engine.setAudioCaptureDevice(deviceId);
}
};
setLocalVideoMirrorType = (type: MirrorType) => {
return this.engine.setLocalVideoMirrorType(type);
};
setLocalVideoPlayer = (
userId: string,
renderDom?: string | HTMLElement,
isScreenShare = false,
renderMode = VideoRenderMode.RENDER_MODE_FILL
) => {
return this.engine.setLocalVideoPlayer(
isScreenShare ? StreamIndex.STREAM_INDEX_SCREEN : StreamIndex.STREAM_INDEX_MAIN,
{
renderDom,
userId,
renderMode,
}
);
};
setRemoteVideoPlayer = (userId: string, renderDom?: string | HTMLElement, renderMode = VideoRenderMode.RENDER_MODE_HIDDEN) => {
return this.engine.setRemoteVideoPlayer(
StreamIndex.STREAM_INDEX_MAIN,
{
renderDom,
userId,
renderMode,
}
);
}
/**
* @brief
*/
removeLocalVideoPlayer = (userId: string, scope: StreamIndex | 'Both' = 'Both') => {
let removeScreen = scope === StreamIndex.STREAM_INDEX_SCREEN;
let removeCamera = scope === StreamIndex.STREAM_INDEX_MAIN;
if (scope === 'Both') {
removeCamera = true;
removeScreen = true;
}
if (removeScreen) {
this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_SCREEN, { userId });
}
if (removeCamera) {
this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, { userId });
}
};
/**
* @brief AIGC
*/
startAgent = async (scene: string) => {
if (this.audioBotEnabled) {
await this.stopAgent(scene);
}
await Apis.VoiceChat.StartVoiceChat({
SceneID: scene,
});
this.audioBotEnabled = true;
this.audioBotStartTime = Date.now();
};
/**
* @brief AIGC
*/
stopAgent = async (scene: string) => {
if (this.audioBotEnabled || sessionStorage.getItem('audioBotEnabled')) {
await Apis.VoiceChat.StopVoiceChat({
SceneID: scene,
});
this.audioBotStartTime = 0;
sessionStorage.removeItem('audioBotEnabled');
}
this.audioBotEnabled = false;
};
/**
* @brief AIGC
*/
commandAgent = ({
command,
agentName,
interruptMode = INTERRUPT_PRIORITY.NONE,
message = '',
}: {
command: COMMAND;
agentName: string;
interruptMode?: INTERRUPT_PRIORITY;
message?: string;
}) => {
if (this.audioBotEnabled) {
this.engine.sendUserBinaryMessage(
agentName,
string2tlv(
JSON.stringify({
Command: command,
InterruptMode: interruptMode,
Message: message,
}),
'ctrl'
)
);
return;
}
console.warn('Interrupt failed, bot not enabled.');
};
/**
* @brief AIGC
*/
updateAgent = async (scene: string) => {
if (this.audioBotEnabled) {
await this.stopAgent(scene);
await this.startAgent(scene);
} else {
await this.startAgent(scene);
}
};
/**
* @brief AI
*/
getAgentEnabled = () => {
return this.audioBotEnabled;
};
}
export default new RTCClient();

View File

@ -1,268 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import VERTC, {
LocalAudioPropertiesInfo,
RemoteAudioPropertiesInfo,
LocalStreamStats,
MediaType,
onUserJoinedEvent,
onUserLeaveEvent,
RemoteStreamStats,
StreamRemoveReason,
StreamIndex,
DeviceInfo,
AutoPlayFailedEvent,
PlayerEvent,
NetworkQuality,
} from '@volcengine/rtc';
import { useDispatch } from 'react-redux';
import { useRef } from 'react';
import {
IUser,
remoteUserJoin,
remoteUserLeave,
updateLocalUser,
updateRemoteUser,
addAutoPlayFail,
removeAutoPlayFail,
updateNetworkQuality,
} from '@/store/slices/room';
import RtcClient, { IEventListener } from './RtcClient';
import { setMicrophoneList, updateSelectedDevice } from '@/store/slices/device';
import { useMessageHandler } from '@/utils/handler';
import store from '@/store';
const useRtcListeners = (): IEventListener => {
const dispatch = useDispatch();
const { parser } = useMessageHandler();
const playStatus = useRef<{ [key: string]: { audio: boolean; video: boolean } }>({});
const handleTrackEnded = async (event: { kind: string; isScreen: boolean }) => {
const { kind, isScreen } = event;
/** 浏览器自带的屏幕共享关闭触发方式,通过 onTrackEnd 事件去关闭 */
if (isScreen && kind === 'video') {
await RtcClient.stopScreenCapture();
await RtcClient.unpublishScreenStream(MediaType.VIDEO);
dispatch(
updateLocalUser({
publishScreen: false,
})
);
}
};
const handleUserJoin = (e: onUserJoinedEvent) => {
const extraInfo = JSON.parse(e.userInfo.extraInfo || '{}');
const userId = extraInfo.user_id || e.userInfo.userId;
const username = extraInfo.user_name || e.userInfo.userId;
dispatch(
remoteUserJoin({
userId,
username,
})
);
};
const handleError = (e: { errorCode: typeof VERTC.ErrorCode.DUPLICATE_LOGIN }) => {
const { errorCode } = e;
if (errorCode === VERTC.ErrorCode.DUPLICATE_LOGIN) {
console.log('踢人');
}
};
const handleUserLeave = (e: onUserLeaveEvent) => {
dispatch(remoteUserLeave(e.userInfo));
dispatch(removeAutoPlayFail(e.userInfo));
};
const handleUserPublishStream = (e: { userId: string; mediaType: MediaType }) => {
const { userId, mediaType } = e;
const payload: IUser = { userId };
if (mediaType === MediaType.AUDIO) {
payload.publishAudio = true;
} else if (mediaType === MediaType.VIDEO) {
payload.publishVideo = true;
} else if (mediaType === MediaType.AUDIO_AND_VIDEO) {
payload.publishAudio = true;
payload.publishVideo = true;
}
const isFullScreen = store.getState().room.isFullScreen;
RtcClient.setRemoteVideoPlayer(userId, isFullScreen ? 'remote-video-player' : 'remote-full-player');
dispatch(updateRemoteUser(payload));
};
const handleUserUnpublishStream = (e: {
userId: string;
mediaType: MediaType;
reason: StreamRemoveReason;
}) => {
const { userId, mediaType } = e;
const payload: IUser = { userId };
if (mediaType === MediaType.AUDIO) {
payload.publishAudio = false;
}
if (mediaType === MediaType.AUDIO_AND_VIDEO) {
payload.publishAudio = false;
}
RtcClient.setRemoteVideoPlayer(userId);
dispatch(updateRemoteUser(payload));
};
const handleRemoteStreamStats = (e: RemoteStreamStats) => {
dispatch(
updateRemoteUser({
userId: e.userId,
audioStats: e.audioStats,
})
);
};
const handleLocalStreamStats = (e: LocalStreamStats) => {
dispatch(
updateLocalUser({
audioStats: e.audioStats,
})
);
};
const handleLocalAudioPropertiesReport = (e: LocalAudioPropertiesInfo[]) => {
const localAudioInfo = e.find(
(audioInfo) => audioInfo.streamIndex === StreamIndex.STREAM_INDEX_MAIN
);
if (localAudioInfo) {
dispatch(
updateLocalUser({
audioPropertiesInfo: localAudioInfo.audioPropertiesInfo,
})
);
}
};
const handleRemoteAudioPropertiesReport = (e: RemoteAudioPropertiesInfo[]) => {
const remoteAudioInfo = e
.filter((audioInfo) => audioInfo.streamKey.streamIndex === StreamIndex.STREAM_INDEX_MAIN)
.map((audioInfo) => ({
userId: audioInfo.streamKey.userId,
audioPropertiesInfo: audioInfo.audioPropertiesInfo,
}));
if (remoteAudioInfo.length) {
dispatch(updateRemoteUser(remoteAudioInfo));
}
};
const handleAudioDeviceStateChanged = async (device: DeviceInfo) => {
const devices = await RtcClient.getDevices();
if (device.mediaDeviceInfo.kind === 'audioinput') {
let deviceId = device.mediaDeviceInfo.deviceId;
if (device.deviceState === 'inactive') {
deviceId = devices.audioInputs?.[0].deviceId || '';
}
RtcClient.switchDevice(MediaType.AUDIO, deviceId);
dispatch(setMicrophoneList(devices.audioInputs));
dispatch(
updateSelectedDevice({
selectedMicrophone: deviceId,
})
);
}
};
const handleAutoPlayFail = (event: AutoPlayFailedEvent) => {
const { userId, kind } = event;
let playUser = playStatus.current?.[userId] || {};
playUser = { ...playUser, [kind]: false };
playStatus.current[userId] = playUser;
dispatch(
addAutoPlayFail({
userId,
})
);
};
const addFailUser = (userId: string) => {
dispatch(addAutoPlayFail({ userId }));
};
const playerFail = (params: { type: 'audio' | 'video'; userId: string }) => {
const { type, userId } = params;
let playUser = playStatus.current?.[userId] || {};
playUser = { ...playUser, [type]: false };
const { audio, video } = playUser;
if (audio === false || video === false) {
addFailUser(userId);
}
return playUser;
};
const handlePlayerEvent = (event: PlayerEvent) => {
const { userId, rawEvent, type } = event;
let playUser = playStatus.current?.[userId] || {};
if (!playStatus.current) return;
if (rawEvent.type === 'playing') {
playUser = { ...playUser, [type]: true };
const { audio, video } = playUser;
if (audio !== false && video !== false) {
dispatch(removeAutoPlayFail({ userId }));
}
} else if (rawEvent.type === 'pause') {
playUser = playerFail({ type, userId });
}
playStatus.current[userId] = playUser;
};
const handleNetworkQuality = (
uplinkNetworkQuality: NetworkQuality,
downlinkNetworkQuality: NetworkQuality
) => {
dispatch(
updateNetworkQuality({
networkQuality: Math.floor(
(uplinkNetworkQuality + downlinkNetworkQuality) / 2
) as NetworkQuality,
})
);
};
const handleRoomBinaryMessageReceived = (event: { userId: string; message: ArrayBuffer }) => {
const { message } = event;
parser(message);
};
return {
handleError,
handleUserJoin,
handleUserLeave,
handleTrackEnded,
handleUserPublishStream,
handleUserUnpublishStream,
handleRemoteStreamStats,
handleLocalStreamStats,
handleLocalAudioPropertiesReport,
handleRemoteAudioPropertiesReport,
handleAudioDeviceStateChanged,
handleAutoPlayFail,
handlePlayerEvent,
handleRoomBinaryMessageReceived,
handleNetworkQuality,
};
};
export default useRtcListeners;

View File

@ -1,272 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useEffect, useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import VERTC, { MediaType } from '@volcengine/rtc';
import { Modal } from '@arco-design/web-react';
import RtcClient from '@/lib/RtcClient';
import {
clearCurrentMsg,
clearHistoryMsg,
localJoinRoom,
localLeaveRoom,
updateAIGCState,
updateLocalUser,
} from '@/store/slices/room';
import useRtcListeners from '@/lib/listenerHooks';
import { RootState } from '@/store';
import {
updateMediaInputs,
updateSelectedDevice,
setDevicePermissions,
} from '@/store/slices/device';
import logger from '@/utils/logger';
export const ABORT_VISIBILITY_CHANGE = 'abortVisibilityChange';
export interface FormProps {
username: string;
roomId: string;
publishAudio: boolean;
}
export const useScene = () => {
const { scene, sceneConfigMap } = useSelector((state: RootState) => state.room);
return sceneConfigMap[scene] || {};
}
export const useRTC = () => {
const { scene, rtcConfigMap } = useSelector((state: RootState) => state.room);
return rtcConfigMap[scene] || {};
}
export const useDeviceState = () => {
const dispatch = useDispatch();
const room = useSelector((state: RootState) => state.room);
const localUser = room.localUser;
const isAudioPublished = localUser.publishAudio;
const isVideoPublished = localUser.publishVideo;
const isScreenPublished = localUser.publishScreen;
const queryDevices = async (type: MediaType) => {
const mediaDevices = await RtcClient.getDevices({
audio: type === MediaType.AUDIO,
video: type === MediaType.VIDEO,
});
if (type === MediaType.AUDIO) {
dispatch(
updateMediaInputs({
audioInputs: mediaDevices.audioInputs,
})
);
dispatch(
updateSelectedDevice({
selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId,
})
);
} else {
dispatch(
updateMediaInputs({
videoInputs: mediaDevices.videoInputs,
})
);
dispatch(
updateSelectedDevice({
selectedCamera: mediaDevices.videoInputs[0]?.deviceId,
})
);
}
return mediaDevices;
};
const switchMic = async (controlPublish = true) => {
if (controlPublish) {
await (!isAudioPublished
? RtcClient.publishStream(MediaType.AUDIO)
: RtcClient.unpublishStream(MediaType.AUDIO));
}
queryDevices(MediaType.AUDIO);
await (!isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture());
dispatch(
updateLocalUser({
publishAudio: !isAudioPublished,
})
);
};
const switchCamera = async (controlPublish = true) => {
if (controlPublish) {
await (!isVideoPublished
? RtcClient.publishStream(MediaType.VIDEO)
: RtcClient.unpublishStream(MediaType.VIDEO));
}
queryDevices(MediaType.VIDEO);
await (!isVideoPublished ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture());
dispatch(
updateLocalUser({
publishVideo: !isVideoPublished,
})
);
};
const switchScreenCapture = async (controlPublish = true) => {
try {
!isScreenPublished
? sessionStorage.setItem(ABORT_VISIBILITY_CHANGE, 'true')
: sessionStorage.removeItem(ABORT_VISIBILITY_CHANGE);
if (controlPublish) {
await (!isScreenPublished
? RtcClient.publishScreenStream(MediaType.VIDEO)
: RtcClient.unpublishScreenStream(MediaType.VIDEO));
}
await (!isScreenPublished ? RtcClient.startScreenCapture() : RtcClient.stopScreenCapture());
dispatch(
updateLocalUser({
publishScreen: !isScreenPublished,
})
);
} catch {
console.warn('Not Authorized.');
}
sessionStorage.removeItem(ABORT_VISIBILITY_CHANGE);
return false;
};
return {
isAudioPublished,
isVideoPublished,
isScreenPublished,
switchMic,
switchCamera,
switchScreenCapture,
};
};
export const useGetDevicePermission = () => {
const [permission, setPermission] = useState<{
audio: boolean;
}>();
const dispatch = useDispatch();
useEffect(() => {
(async () => {
const permission = await RtcClient.checkPermission();
dispatch(setDevicePermissions(permission));
setPermission(permission);
})();
}, [dispatch]);
return permission;
};
export const useJoin = (): [
boolean,
() => Promise<void | boolean>
] => {
const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions);
const room = useSelector((state: RootState) => state.room);
const dispatch = useDispatch();
const { id } = useScene();
const { switchMic } = useDeviceState();
const [joining, setJoining] = useState(false);
const listeners = useRtcListeners();
const handleAIGCModeStart = async () => {
if (room.isAIGCEnable) {
await RtcClient.stopAgent(id);
dispatch(clearCurrentMsg());
await RtcClient.startAgent(id);
} else {
await RtcClient.startAgent(id);
}
dispatch(updateAIGCState({ isAIGCEnable: true }));
};
async function disPatchJoin(): Promise<boolean | undefined> {
if (joining) {
return;
}
const isSupported = await VERTC.isSupported();
if (!isSupported) {
Modal.error({
title: '不支持 RTC',
content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。',
});
return;
}
setJoining(true);
/** 1. Create RTC Engine */
await RtcClient.createEngine();
/** 2.1 Set events callbacks */
RtcClient.addEventListeners(listeners);
/** 2.2 RTC starting to join room */
await RtcClient.joinRoom();
/** 3. Set users' devices info */
const mediaDevices = await RtcClient.getDevices({
audio: true,
video: false,
});
dispatch(
localJoinRoom({
roomId: RtcClient.basicInfo.room_id,
user: {
username: RtcClient.basicInfo.user_id,
userId: RtcClient.basicInfo.user_id,
},
})
);
dispatch(
updateSelectedDevice({
selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId,
selectedCamera: mediaDevices.videoInputs[0]?.deviceId,
})
);
dispatch(updateMediaInputs(mediaDevices));
setJoining(false);
if (devicePermissions.audio) {
try {
await switchMic();
} catch (e) {
logger.debug('No permission for mic');
}
}
handleAIGCModeStart();
}
return [joining, disPatchJoin];
};
export const useLeave = () => {
const dispatch = useDispatch();
const { id } = useScene();
const idRef = useRef(id);
idRef.current = id;
return async function () {
await Promise.all([
RtcClient.stopAudioCapture,
RtcClient.stopScreenCapture,
RtcClient.stopVideoCapture,
]);
await RtcClient.stopAgent(idRef.current);
await RtcClient.leaveRoom();
dispatch(clearHistoryMsg());
dispatch(clearCurrentMsg());
dispatch(localLeaveRoom());
dispatch(updateAIGCState({ isAIGCEnable: false }));
};
};

View File

@ -1,68 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.btn {
width: max-content;
height: max-content;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
.icon {
position: absolute;
}
}
.text {
margin-top: 8px;
color: rgba(115, 122, 135, 1);
}
}
.cursor {
cursor: pointer;
}
.cursor:hover {
opacity: 0.8;
}
.cursor:active {
opacity: 1;
}
.loader {
display: flex;
gap: 5px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: white;
animation: glow 0.9s infinite;
}
@keyframes glow {
0% {
opacity: 1;
}
40% {
opacity: 0.7;
}
100% {
opacity: 0.3;
}
}

View File

@ -1,33 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import Loading from './loading';
import style from './index.module.less';
import CallButtonSVG from '@/assets/img/CallWrapper.svg';
import PhoneSVG from '@/assets/img/Phone.svg';
interface IInvokeButtonProps extends React.HTMLAttributes<HTMLDivElement> {
loading?: boolean;
}
function InvokeButton(props: IInvokeButtonProps) {
const { loading, className, ...rest } = props;
return (
<div className={`${style.wrapper} ${loading ? '' : style.cursor} ${className}`} {...rest}>
<div className={style.btn}>
<img src={CallButtonSVG} alt="call" />
{loading ? (
<Loading className={style.icon} />
) : (
<img src={PhoneSVG} className={style.icon} alt="phone" />
)}
</div>
<div className={style.text}>{loading ? '连接中' : '通话'}</div>
</div>
);
}
export default InvokeButton;

View File

@ -1,27 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import style from './index.module.less';
function Loading(props: React.HTMLAttributes<HTMLDivElement>) {
const { className = '', ...rest } = props;
return (
<div className={`${style.loader} ${className}`} {...rest}>
{Array(3)
.fill(0)
.map((_, index) => (
<div
key={index}
className={style.dot}
style={{
animationDelay: `${index * 0.3}s`,
}}
/>
))}
</div>
);
}
export default Loading;

View File

@ -1,69 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
position: relative;
width: 100%;
height: 100%;
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.avatar {
/**
* height = 128px in AvatarCard.avatar
*
*/
margin-top: -128px;
/**
* width = 128px in AvatarCard.avatar
* 128px / 2 = 64px
*
*/
transform: translateX(calc(50% - 64px));
}
.mobile {
transform: none !important;
}
.description {
font-family: PingFang SC;
font-weight: 400;
font-size: 10px;
line-height: 20px;
color: #c7ccd6;
position: absolute;
bottom: 24px;
left: 24px;
}
.invoke-btn {
margin-top: 32px;
}
.mobileDesc {
font-weight: 400;
font-size: 14px;
line-height: 22px;
text-align: center;
color: #737a87;
position: absolute;
bottom: 12px;
}
}
.mobile {
background:
/* 图层1 (最上层): 背景图片 */
/* url(...) [position] / [size] [repeat] */ url('../../../../assets/img/mobileBg.png')
center center / cover no-repeat,
/* 图层2 (下层): 渐变背景 */ linear-gradient(167.98deg, #f5f7ff 0%, #faf3ff 100%);
border-radius: 0;
}

View File

@ -1,39 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useDispatch } from 'react-redux';
import { isMobile } from '@/utils/utils';
import InvokeButton from '@/pages/MainPage/MainArea/Antechamber/InvokeButton';
import { useJoin, useScene } from '@/lib/useCommon';
import AIChangeCard from '@/components/AiChangeCard';
import { updateFullScreen, updateShowSubtitle } from '@/store/slices/room';
import style from './index.module.less';
function Antechamber() {
const dispatch = useDispatch();
const [joining, dispatchJoin] = useJoin();
const { isScreenMode, isAvatarScene } = useScene();
const handleJoinRoom = () => {
dispatch(updateFullScreen({ isFullScreen: !isMobile() && !isScreenMode && !isAvatarScene })); // 初始化
dispatch(updateShowSubtitle({ isShowSubtitle: !isAvatarScene }));
if (!joining) {
dispatchJoin();
}
};
return (
<div className={`${style.wrapper} ${isMobile() ? style.mobile : ''}`}>
<AIChangeCard />
<InvokeButton onClick={handleJoinRoom} loading={joining} className={style['invoke-btn']} />
{isMobile() ? null : (
<div className={style.description}>Powered by RTC</div>
)}
</div>
);
}
export default Antechamber;

View File

@ -1,56 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useDispatch, useSelector } from 'react-redux';
import AudioLoading from '@/components/Loading/AudioLoading';
import { RootState } from '@/store';
import RtcClient from '@/lib/RtcClient';
import { setInterruptMsg } from '@/store/slices/room';
import { useDeviceState, useScene } from '@/lib/useCommon';
import { COMMAND } from '@/utils/handler';
import style from './index.module.less';
const THRESHOLD_VOLUME = 18;
function AudioController(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const dispatch = useDispatch();
const { isInterruptMode, botName } = useScene();
const room = useSelector((state: RootState) => state.room);
const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0;
const { isAudioPublished } = useDeviceState();
const { isAITalking } = room;
const isAIReady = room.msgHistory.length > 0;
const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished;
const handleInterrupt = () => {
RtcClient.commandAgent({
agentName: botName,
command: COMMAND.INTERRUPT,
});
dispatch(setInterruptMsg());
};
return (
<div className={`${className}`} {...rest}>
{isAudioPublished ? (
isAIReady && isAITalking ? (
<div className={style.interruptContainer}>
{isInterruptMode ? <div> </div> : null}
<div onClick={handleInterrupt} className={style.interrupt}>
<div className={style.interruptIcon} />
<span></span>
</div>
</div>
) : isLoading ? null : (
<div className={style.closed}></div>
)
) : (
<div className={style.closed}></div>
)}
<AudioLoading loading={isLoading} color={isAudioPublished ? undefined : '#EAEDF1'} />
</div>
);
}
export default AudioController;

View File

@ -1,128 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useSelector } from 'react-redux';
import { VideoRenderMode } from '@volcengine/rtc';
import { useEffect } from 'react';
import { RootState } from '@/store';
import { useDeviceState, useScene } from '@/lib/useCommon';
import RtcClient from '@/lib/RtcClient';
import styles from './index.module.less';
import UserTag from '@/components/UserTag';
import LocalPlayerSet from '@/components/LocalPlayerSet';
import AiAvatarCard from '@/components/AiAvatarCard';
import UserAvatar from '@/assets/img/userAvatar.png';
import CameraCloseNoteSVG from '@/assets/img/CameraCloseNote.svg';
import ScreenCloseNoteSVG from '@/assets/img/ScreenCloseNote.svg';
import { LocalFullID, RemoteFullID } from '@/components/FullScreenCard';
const LocalVideoID = 'local-video-player';
const LocalScreenID = 'local-screen-player';
const RemoteVideoID = 'remote-video-player';
function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const room = useSelector((state: RootState) => state.room);
const { isFullScreen, scene } = room;
const { isVision, isScreenMode, botName } = useScene();
const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } =
useDeviceState();
const isRemoteVideoPublished = room.remoteUsers.find(user => user.username === botName)?.publishVideo ?? false
const setVideoPlayer = () => {
RtcClient.removeLocalVideoPlayer(room.localUser.username!);
if (isVideoPublished || isScreenPublished) {
RtcClient.setLocalVideoPlayer(
room.localUser.username!,
isFullScreen ? LocalFullID : isScreenMode ? LocalScreenID : LocalVideoID,
isScreenPublished,
isScreenMode ? VideoRenderMode.RENDER_MODE_FILL : VideoRenderMode.RENDER_MODE_HIDDEN
);
if(isRemoteVideoPublished) {
RtcClient.setRemoteVideoPlayer(
botName,
isFullScreen ? RemoteVideoID : RemoteFullID,
);
}
}
};
const handleOperateCamera = () => {
switchCamera();
};
const handleOperateScreenShare = () => {
switchScreenCapture();
};
useEffect(() => {
setVideoPlayer();
}, [isVideoPublished, isScreenPublished, isScreenMode, isFullScreen, isVision]);
return (
<div className={`${styles['camera-wrapper']} ${className}`} {...rest}>
<UserTag name={isFullScreen ? scene : '我'} className={styles.userTag} />
{isFullScreen ? (
<AiAvatarCard showUserTag={false} showStatus className={styles.fullScreenAiAvatar} />
) : null}
{isVideoPublished || isScreenPublished ? <LocalPlayerSet /> : null}
<div
id={LocalVideoID}
className={`${styles['camera-player']} ${
isVideoPublished && !isScreenMode ? '' : styles['camera-player-hidden']
}`}
/>
<div
id={LocalScreenID}
className={`${styles['camera-player']} ${
isScreenPublished && isScreenMode ? '' : styles['camera-player-hidden']
}`}
/>
<div
id={RemoteVideoID}
className={`${styles['camera-player']} ${
isFullScreen && isRemoteVideoPublished ? '' : styles['camera-player-hidden']
}`}
style={{ position: 'absolute' }}
/>
<div
className={`${styles['camera-placeholder']} ${
isVideoPublished || isScreenPublished ? styles['camera-player-hidden'] : ''
}`}
>
<img
src={isScreenMode ? ScreenCloseNoteSVG : isVision ? CameraCloseNoteSVG : UserAvatar}
alt="close"
className={styles['camera-placeholder-close-note']}
/>
{isFullScreen ? null : (
<div>
{isScreenMode ? (
<>
<span onClick={handleOperateScreenShare} className={styles['camera-open-btn']}>
</span>
<div></div>
</>
) : isVision ? (
<>
<span onClick={handleOperateCamera} className={styles['camera-open-btn']}>
</span>
<div></div>
</>
) : null}
</div>
)}
</div>
</div>
);
}
export default CameraArea;

View File

@ -1,113 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import React, { useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Tag, Spin } from '@arco-design/web-react';
import { RootState } from '@/store';
import Loading from '@/components/Loading/HorizonLoading';
import { isMobile } from '@/utils/utils';
import { useScene } from '@/lib/useCommon';
import USER_AVATAR from '@/assets/img/userAvatar.png';
import styles from './index.module.less';
import AIAvatarReadying from '@/components/AIAvatarLoading';
const lines: (string | React.ReactNode)[] = [];
function Conversation(props: React.HTMLAttributes<HTMLDivElement> & { showSubtitle: boolean }) {
const { className, showSubtitle, ...rest } = props;
const room = useSelector((state: RootState) => state.room);
const { msgHistory, isFullScreen } = room;
const { userId } = useSelector((state: RootState) => state.room.localUser);
const { isAITalking, isUserTalking, scene } = useSelector((state: RootState) => state.room);
const isAIReady = msgHistory.length > 0;
const containerRef = useRef<HTMLDivElement>(null);
const { botName, icon, isAvatarScene } = useScene();
const isUserTextLoading = (owner: string) => {
return owner === userId && isUserTalking;
};
const isAITextLoading = (owner: string) => {
return (owner === botName || owner.includes('voiceChat_')) && isAITalking;
};
useEffect(() => {
const container = containerRef.current;
if (container) {
container.scrollTop = container.scrollHeight - container.clientHeight;
}
}, [msgHistory.length]);
return (
<div
ref={containerRef}
className={`${styles.conversation} ${className} ${isFullScreen ? styles.fullScreen : ''} ${
isMobile() ? styles.mobileConversation : ''
}`}
style={isAvatarScene && !isAIReady ? { justifyContent: 'center' } : {}}
{...rest}
>
{lines.map((line) => line)}
{!isAIReady ? (
<div className={styles.aiReadying}>
{isAvatarScene ? (
<AIAvatarReadying />
) : (
<>
<Spin size={16} className={styles['aiReading-spin']} />
AI ,
</>
)}
</div>
) : (
''
)}
{(showSubtitle ? msgHistory : [])?.map(({ value, user, isInterrupted }, index) => {
const isUserMsg = user === userId;
const isRobotMsg = user === botName || user.includes('voiceChat_');
if (!isUserMsg && !isRobotMsg) {
return '';
}
return (
<div
key={`msg-container-${index}`}
className={styles.mobileLine}
style={{ justifyContent: isUserMsg && isMobile() ? 'flex-end' : '' }}
>
{!isMobile() && (
<div className={styles.msgName}>
<div className={styles.avatar}>
<img src={isUserMsg ? USER_AVATAR : icon} alt="Avatar" />
</div>
{isUserMsg ? '我' : scene}
</div>
)}
<div
className={`${styles.sentence} ${isUserMsg ? styles.user : styles.robot}`}
key={`msg-${index}`}
>
<div className={styles.content}>
{value}
<div className={styles['loading-wrapper']}>
{isAIReady &&
(isUserTextLoading(user) || isAITextLoading(user)) &&
index === msgHistory.length - 1 ? (
<Loading gap={3} className={styles.loading} dotClassName={styles.dot} />
) : (
''
)}
</div>
</div>
{!isUserMsg && isInterrupted ? <Tag className={styles.interruptTag}></Tag> : ''}
</div>
</div>
);
})}
</div>
);
}
export default Conversation;

View File

@ -1,83 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { memo, useState } from 'react';
import { Drawer } from '@arco-design/web-react';
import { useDeviceState, useLeave, useScene } from '@/lib/useCommon';
import { isMobile } from '@/utils/utils';
import Menu from '../../Menu';
import style from './index.module.less';
import CameraOpenSVG from '@/assets/img/CameraOpen.svg';
import CameraCloseSVG from '@/assets/img/CameraClose.svg';
import MicOpenSVG from '@/assets/img/MicOpen.svg';
import MicCloseSVG from '@/assets/img/MicClose.svg';
import LeaveRoomSVG from '@/assets/img/LeaveRoom.svg';
import ScreenOnSVG from '@/assets/img/ScreenOn.svg';
import ScreenOffSVG from '@/assets/img/ScreenOff.svg';
function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const [open, setOpen] = useState(false);
const { isVision, isScreenMode } = useScene();
const leaveRoom = useLeave();
const {
isAudioPublished,
isVideoPublished,
isScreenPublished,
switchMic,
switchCamera,
switchScreenCapture,
} = useDeviceState();
return (
<div className={`${className} ${style.btns} ${isMobile() ? style.column : ''}`} {...rest}>
<img
src={isAudioPublished ? MicOpenSVG : MicCloseSVG}
onClick={() => switchMic(true)}
className={style.btn}
alt="mic"
/>
{!isVision ? null : isScreenMode && !isMobile() ? (
<img
src={isScreenPublished ? 'new-screen-off.svg' : 'new-screen-on.svg'}
onClick={() => switchScreenCapture()}
className={style.btn}
alt="screenShare"
/>
) : (
<img
src={isVideoPublished ? CameraOpenSVG : CameraCloseSVG}
onClick={() => switchCamera(true)}
className={style.btn}
alt="camera"
/>
)}
{isScreenMode && (
<img
src={isScreenPublished ? ScreenOnSVG : ScreenOffSVG}
onClick={() => switchScreenCapture(true)}
className={style.btn}
alt="screenShare"
/>
)}
<img src={LeaveRoomSVG} onClick={leaveRoom} className={style.btn} alt="leave" />
{isMobile() ? (
<Drawer
title="设置"
visible={open}
onCancel={() => setOpen(false)}
style={{
width: 'max-content',
}}
footer={null}
>
<Menu />
</Drawer>
) : null}
</div>
);
}
export default memo(ToolBar);

View File

@ -1,430 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
position: relative;
width: 100%;
height: 100%;
border-radius: 16px;
padding: 32px;
box-sizing: border-box;
.conversation,
.fullScreen,
.mobileConversation {
width: 100%;
position: relative;
height: 100%;
/**
* 100% 为容器高度
* 128px 为上层 DouBao Card Height
* 24px 为 margin top
* 36px * 2 为容器 padding
* 128 + 24 + 36 * 2 = 224px
*/
max-height: calc(100% - 224px - 8px);
display: flex;
flex-direction: column;
padding-bottom: 12px;
overflow-x: hidden;
overflow-y: auto;
margin-top: 48px;
.sentence {
position: relative;
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-wrap: wrap;
align-items: center;
width: max-content;
white-space: normal;
max-width: 70%;
padding: 12px 16px;
margin-left: 32px;
gap: 8px;
.content {
width: max-content;
}
}
.user {
width: max-content;
border: 0px solid;
padding: 8px 12px 8px 12px;
border-radius: 12px;
background: #f1f3f5;
margin-bottom: 12px;
}
.robot {
font-family: PingFang SC;
color: #0c0d0e;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.003em;
border: 1px solid transparent;
border-radius: 12px;
background: linear-gradient(77.86deg, #fff -3.23%, #fff 51.11%, #fff 98.65%) padding-box,
linear-gradient(77.86deg, #e5f2ff -3.23%, #d9e5ff 51.11%, #f6e2ff 98.65%) border-box;
margin-bottom: 12px;
}
.loading-wrapper {
width: max-content;
display: inline-block;
.loading {
margin-left: 8px;
width: max-content;
}
.dot {
background-color: rgba(193, 163, 237, 1);
width: 8px;
height: 8px;
}
}
.aiReadying {
font-family: PingFang SC;
font-size: 16px;
font-weight: 500;
color: rgba(27, 30, 61, 0.6);
text-align: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
line-height: 28px;
}
.aiReading-spin {
margin-right: 12px;
line-height: 16px;
}
.msgName {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
line-height: 20px;
color: #737a87;
margin-bottom: 4px;
.avatar {
border-radius: 50%;
width: 24px;
height: 24px;
img {
width: 100%;
height: 100%;
}
}
}
}
.fullScreen {
.msgName {
color: #fff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
}
.sentence {
color: #fff;
}
.user {
background: rgba(0, 0, 0, 0.25);
}
.robot {
background: rgba(0, 12, 71, 0.5);
}
}
.conversation::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.conversation::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0);
border-radius: 0px;
}
.conversation::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
border-radius: 0px;
}
.toolBar {
position: absolute;
right: 0px;
margin-right: 36px;
bottom: 36px;
}
.controller {
position: absolute;
left: 0px;
bottom: 36px;
margin-left: 50%;
transform: translateX(-50%);
}
.declare {
position: absolute;
bottom: 8px;
left: 12px;
color: var(--text-color-text-4, rgba(199, 204, 214, 1));
font-size: 10px;
font-weight: 400;
line-height: 20px;
}
}
.text {
width: 100%;
text-align: center;
color: rgba(148, 116, 255, 1);
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.closed {
width: 100%;
text-align: center;
color: #737a87;
font-size: 14px;
font-weight: 400;
line-height: 19.6px;
}
.btns {
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 16px;
.setting {
background-color: rgba(111, 111, 111, 0.497);
border-radius: 50%;
width: 48px;
height: 48px;
padding: 12px;
box-sizing: border-box;
cursor: pointer;
}
.btn {
cursor: pointer;
}
.btn:hover {
opacity: 0.8;
}
.btn:active {
opacity: 1;
}
}
.column {
margin-right: 0 !important;
justify-content: space-around;
align-items: center;
bottom: 64px !important;
gap: 0;
img {
width: 84px;
height: 84px;
}
}
.interruptContainer {
color: #635bff;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
}
.interruptIcon {
display: inline-block;
width: 8px;
height: 8px;
background-color: #635bff;
border-radius: 2px;
}
.interrupt {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background: rgba(99, 91, 255, 0.1);
border-radius: 4px;
width: max-content;
height: 26px;
padding: 0 8px;
gap: 4px;
cursor: pointer;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
&:hover {
opacity: 0.8;
}
&:active {
opacity: 1;
}
}
.camera-wrapper {
position: absolute;
top: 16px;
right: 16px;
width: 264px;
border-radius: 8px;
background: var(--line-color-border-2, rgba(234, 237, 241, 1));
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 0.81px solid var(--line-color-border-3, rgba(221, 226, 233, 1));
z-index: 4;
.camera-player {
width: 100%;
height: 184px;
border-radius: 8px;
overflow: hidden;
}
.camera-player-hidden {
display: none !important;
}
.camera-placeholder {
width: 100%;
height: 184px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 12px;
color: #737a87;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
text-align: center;
.camera-placeholder-close-note {
margin-bottom: 8px;
width: 60px;
height: 60px;
}
.camera-open-btn {
color: var(--primary-color-primary-6, rgba(22, 100, 255, 1));
cursor: pointer;
margin-left: 2px;
}
}
.userTag {
position: absolute;
top: 4px;
left: 4px;
}
.subTitleUserTag {
position: absolute;
top: -16px;
right: -16px;
}
}
.visionDescriptionArea {
width: 100%;
background: linear-gradient(77.86deg, #f1f9ff -3.23%, #edf3ff 51.11%, #faf4ff 98.65%);
padding: 10px 0;
text-align: center;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
box-sizing: border-box;
font-size: 12px;
line-height: 20px;
color: #737a87;
.visionTitleText {
color: #42464e;
font-weight: 500;
}
}
.subtitleAiAvatar {
opacity: 0.3;
}
.fullScreenAiAvatar {
height: 184px;
}
.mobile {
background:
/* 图层1 (最上层): 背景图片 */
/* url(...) [position] / [size] [repeat] */ url('../../../../assets/img/mobileBg.png')
center center / cover no-repeat,
/* 图层2 (下层): 渐变背景 */ linear-gradient(167.98deg, #f5f7ff 0%, #faf3ff 100%);
.controller {
bottom: 156px;
}
border-radius: 0;
}
.mobileConversation {
display: flex;
max-height: calc(100% - 324px) !important;
margin-top: 64px !important;
.sentence {
margin-left: 0 !important;
max-width: 85% !important;
}
.mobileLine {
display: flex;
}
}
.mobilePlayer {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
@media (max-width: 767px) {
.mobileLine {
display: flex;
justify-content: flex-start;
}
.user {
align-self: flex-end;
}
}

View File

@ -1,49 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useSelector } from 'react-redux';
import Conversation from './Conversation';
import ToolBar from './ToolBar';
import CameraArea from './CameraArea';
import AudioController from './AudioController';
import { isMobile } from '@/utils/utils';
import style from './index.module.less';
import AiAvatarCard from '@/components/AiAvatarCard';
import { RootState } from '@/store';
import UserTag from '@/components/UserTag';
import FullScreenCard from '@/components/FullScreenCard';
import MobileToolBar from '@/pages/Mobile/MobileToolBar';
import { useScene } from '@/lib/useCommon';
function Room() {
const room = useSelector((state: RootState) => state.room);
const { isShowSubtitle, scene, isFullScreen } = room;
const { isAvatarScene } = useScene();
return (
<div className={`${style.wrapper} ${isMobile() ? style.mobile : ''}`}>
{isMobile() ? <div className={style.mobilePlayer} id="mobile-local-player" /> : null}
{isMobile() ? <MobileToolBar /> : null}
{isShowSubtitle && !isMobile() ? (
<UserTag name={scene} className={style.subTitleUserTag} />
) : null}
{isAvatarScene || (isFullScreen && !isMobile()) ? (
<FullScreenCard />
) : isMobile() && isShowSubtitle ? null : (
<AiAvatarCard
showUserTag={!isShowSubtitle}
showStatus={!isShowSubtitle}
className={isShowSubtitle ? style.subtitleAiAvatar : ''}
/>
)}
{isMobile() ? null : <CameraArea />}
<Conversation className={style.conversation} showSubtitle={isShowSubtitle} />
<ToolBar className={style.toolBar} />
<AudioController className={style.controller} />
<div className={style.declare}>AI生成内容由大模型生成</div>
</div>
);
}
export default Room;

View File

@ -1,262 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
width: 100%;
height: 100%;
background-color: white;
border: 1px solid var(--line-color-border-2, rgba(234, 237, 241, 1));
border-radius: 16px;
padding: 20px 12.5%;
.space {
width: 100%;
min-height: 40px;
}
.doubaoIcon {
width: 111px;
height: 111px;
min-height: 111px;
overflow: hidden;
}
.interruptTag {
width: max-content;
height: 22px;
padding: 0px 6px 0px 6px;
border-radius: 4px;
margin-left: 4px;
font-family: PingFang SC;
font-size: 12px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0.003em;
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
background: var(--security-unknown-tag-unknown-1, rgba(241, 243, 245, 1));
}
.welcome {
font-family: PingFang SC;
font-size: 24px;
font-weight: 500;
line-height: 32px;
letter-spacing: 0.003em;
text-align: left;
margin-top: 8px;
}
.weight {
background: linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tip {
font-family: PingFang SC;
font-size: 13px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0.003em;
text-align: left;
color: rgba(27, 30, 61, 0.6);
margin-top: 18px;
margin-bottom: 18px;
}
.tagProblem {
width: max-content;
border-radius: 4px;
font-family: PingFang SC;
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.003em;
text-align: center;
margin-bottom: 12px;
color: rgba(66, 70, 78, 1);
}
.conversation {
overflow-x: hidden;
overflow-y: auto;
width: 100%;
position: relative;
height: calc(75% - 12px);
display: flex;
flex-direction: column;
padding-bottom: 12px;
.aiReadying {
font-family: PingFang SC;
font-size: 16px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.003em;
color: rgba(27, 30, 61, 0.6);
margin-top: 12px;
text-align: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.aiReading-spin {
margin-right: 12px;
}
}
.conversation::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.conversation::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0);
border-radius: 0px;
}
.conversation::-webkit-scrollbar-track {
background: rgba(0,0,0,0);
border-radius: 0px;
}
.sentence {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.user {
width: max-content;
border: 0px solid;
align-self: flex-end;
padding: 8px 12px 8px 12px;
border-radius: 12px 0px 12px 12px;
background: var(--background-color-bg-5, rgba(241, 243, 245, 1));
margin-top: 12px;
}
.robot {
font-family: PingFang SC;
font-size: 14px;
font-weight: 400;
letter-spacing: 0.003em;
border: 0px solid;
align-self: flex-start;
padding: 3px 12px 3px 0px;
}
.userTalkingWave {
height: 100px;
}
.userStopTalkingWave {
height: 100px;
transform: scaleY(.5);
}
.status {
overflow: hidden;
width: 100%;
height: 25%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 8px;
.status-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.status-icon {
width: 24px;
height: 24px;
margin-right: 6px;
}
.status-text {
font-family: PingFang SC;
font-size: 14px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.003em;
}
}
.desc {
font-family: PingFang SC;
font-size: 10px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.003em;
text-align: center;
color: var(--text-color-text-4, rgba(199, 204, 214, 1));
}
.micNotify {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.micReopen {
position: relative;
width: 107px;
height: 40px;
padding: 5px 16px 5px 16px;
margin-left: 12px;
margin-right: 12px;
background-clip: padding-box; /* 确保背景不覆盖边框 */
border-radius: 12px;
&:hover,
&:active,
&:focus {
opacity: 1;
color: rgba(0, 0, 0, 0.85);
border-color: #d9d9d9;
}
}
}
.interrupt {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-top: 12px;
width: max-content;
line-height: 28px;
padding: 1px 6px 1px 6px;
border-radius: 4px;
margin-left: 4px;
font-family: PingFang SC;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.003em;
text-align: left;
box-shadow: 0px 0px 0px 1px rgba(221, 226, 233, 1);
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
&:hover,
&:active,
&:focus {
opacity: 1;
border-color: #d9d9d9;
}
img {
margin-right: 8px;
}
}
}

Some files were not shown because too many files have changed in this diff Show More