Init frontend (#1)

更新静态资源
This commit is contained in:
bingochaos
2025-08-07 18:18:54 +08:00
committed by GitHub
parent 10b28534be
commit 3fddf021be
67 changed files with 11271 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 100
quote_type = single
trim_trailing_whitespace = true
semi = false

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# dist
dist-ssr
*.local
# Editor directories and files
.vscode
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.ci
#package-lock.json

3
.husky/pre-commit Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
PATH=$(pwd)/.node/bin:$(pwd)/node_modules/.bin:$PATH lint-staged

11
.prettierignore Normal file
View File

@@ -0,0 +1,11 @@
build
coverage
dist
es
lib
node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
*.min.js
*.min.css

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"tabWidth": 2,
"semi": false,
"arrowParens": "always",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }]
}

10
.stylelintignore Normal file
View File

@@ -0,0 +1,10 @@
build
coverage
dist
es
lib
node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
*.min.css

0
README.md Normal file
View File

BIN
dist/assets/background.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

56
dist/assets/index-legacy.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index.css vendored Normal file

File diff suppressed because one or more lines are too long

59
dist/assets/index.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/assets/polyfills-legacy.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/polyfills.js vendored Normal file

File diff suppressed because one or more lines are too long

21
dist/index.html vendored Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en" data-prefers-color-scheme="light">
<head>
<script type="module" crossorigin src="./assets/polyfills.js"></script>
<meta charset="UTF-8" />
<title>OpenAvatarChat</title>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
<script type="module">import.meta.url;import("_").catch(()=>1);(async function*(){})().next();window.__vite_is_modern_browser=true</script>
<script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
</head>
<body>
<div id="app"></div>
<script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
<script nomodule crossorigin id="vite-legacy-polyfill" src="./assets/polyfills-legacy.js"></script>
<script nomodule crossorigin id="vite-legacy-entry" data-src="./assets/index-legacy.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
</body>
</html>

37
eslint.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import { base as aliBase } from 'eslint-config-ali';
import prettier from 'eslint-plugin-prettier/recommended';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
// import tslintPlugin from 'typescript-eslint';
export default defineConfig([
// ...tslintPlugin.configs.recommended,
...aliBase,
prettier,
{
ignores: ['dist/**/*', 'node_modules/**/*'],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@/semi': [1, 'never'],
'prettier/prettier': [
'error',
{
printWidth: 80,
singleQuote: true,
trailingComma: 'all',
proseWrap: 'never',
tabWidth: 2,
semi: false,
arrowParens: 'always',
},
],
},
},
globalIgnores(['**/dist/**', '**/node_modules/**']),
]);

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" data-prefers-color-scheme="light">
<head>
<meta charset="UTF-8" />
<title>OpenAvatarChat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

84
package.json Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "open-avatar-chat-webui",
"version": "4.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vue-tsc && vite build",
"ci:eslint": "eslint -f json src -o ./.ci/eslint.json",
"ci:test": "vitest -c ./vitest.config.ts --coverage",
"dev": "vite",
"eslint": "eslint --fix --ext .js,.ts,.vue src",
"format": "prettier --write --cache --parser typescript \"**/*.[tj]s?(x)\"",
"lint": "eslint . && stylelint --allow-empty-input \"**/*.{css,less,scss}\"",
"lint-staged": "lint-staged",
"lint:fix": "prettier --write . && eslint --fix . && stylelint --allow-empty-input --fix \"**/*.{css,less,scss}\"",
"local": "sudo vite",
"prepare": "husky",
"preview": "vite preview",
"test": "vitest"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx,vue}": "eslint --fix",
"*.{cjs,css,cts,html,js,json,jsx,less,md,mjs,mts,scss,ts,tsx,vue,yaml,yml}": "prettier --write"
},
"prettier": "prettier-config-ali",
"stylelint": {
"extends": [
"stylelint-config-ali",
"stylelint-prettier/recommended"
]
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.10.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"click-outside-vue3": "^4.0.1",
"eventemitter3": "^5.0.1",
"gaussian-splat-renderer-for-lam": "^0.0.8",
"hls.js": "^1.5.16",
"mrmime": "^2.0.0",
"nanoid": "^5.1.5",
"p-queue": "^8.0.1",
"pinia": "^3.0.3",
"python-struct": "^1.1.3",
"vue": "^3.5.17",
"vue-i18n": "^11.1.9"
},
"devDependencies": {
"@commitlint/config-conventional": "^19.8.1",
"@types/node": "^20.17.6",
"@types/python-struct": "^1.0.4",
"@vitejs/plugin-legacy": "^7.0.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"eslint": "^9.31.0",
"eslint-config-ali": "^16.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-vue": "^10.3.0",
"globals": "^16.3.0",
"husky": "^9.1.7",
"less": "^4.2.0",
"lint-staged": "^16.1.2",
"prettier": "^3.6.2",
"prettier-config-ali": "^1.3.4",
"simple-git-hooks": "^2.13.0",
"stylelint": "^16.22.0",
"stylelint-config-ali": "^2.1.2",
"stylelint-config-standard": "^38.0.0",
"stylelint-prettier": "^5.0.3",
"terser": "^5.36.0",
"typescript": "5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.1",
"vite-plugin-eslint2": "^5.0.4",
"vite-plugin-mkcert": "^1.17.6",
"vite-plugin-stylelint": "^6.0.2",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.1"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

6859
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

32
src/App.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import WebcamPermission from '@/components/WebcamPermission.vue';
import { antdLocale, locale } from '@/langs';
import VideoChat from '@/views/VideoChat/index.vue';
import { ConfigProvider } from 'ant-design-vue';
import { useVideoChatStore } from './store';
const videoChatState = useVideoChatStore();
videoChatState.init();
// import dayjs from 'dayjs';
// import 'dayjs/locale/zh-cn';
// dayjs.locale('zh-cn');
</script>
<template>
<ConfigProvider :locale="antdLocale[locale]">
<div class="wrap">
<WebcamPermission v-if="!videoChatState.webcamAccessed" />
<VideoChat />
</div>
</ConfigProvider>
</template>
<style lang="less" scoped>
.wrap {
background-image: url(@/assets/background.png);
height: calc(max(80vh, 100%));
background-size: 100% 100%;
background-repeat: no-repeat;
position: relative;
*::-webkit-scrollbar {
display: none;
}
}
</style>

BIN
src/assets/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -0,0 +1,235 @@
<template>
<div class="action-group">
<div v-if="hasCamera">
<div class="action" @click="handleCameraOff" v-click-outside="() => (cameraListShow = false)">
<Iconfont :icon="cameraOff ? CameraOff : CameraOn" />
<div
v-if="streamState === 'closed'"
class="corner"
@click.stop.prevent="() => (cameraListShow = !cameraListShow)"
>
<div class="corner-inner"></div>
</div>
<div
class="selectors"
:class="{ left: isLandscape }"
v-show="cameraListShow && streamState === 'closed'"
>
<div
v-for="device in availableVideoDevices"
:key="device.deviceId"
class="selector"
@click.stop="
() => {
handleDeviceChange(device.deviceId);
cameraListShow = false;
}
"
>
{{ device.label }}
<div
v-if="selectedVideoDevice && device.deviceId === selectedVideoDevice.deviceId"
class="active-icon"
>
<CheckIcon />
</div>
</div>
</div>
</div>
</div>
<div v-if="hasMic">
<div class="action" @click="handleMicMuted" v-click-outside="() => (micListShow = false)">
<Iconfont :icon="micMuted ? MicOff : MicOn" />
<div
v-if="streamState === 'closed'"
class="corner"
@click.stop.prevent="() => (micListShow = !micListShow)"
>
<div class="corner-inner"></div>
</div>
<div
class="selectors"
:class="{ left: isLandscape }"
v-show="micListShow && streamState === 'closed'"
>
<div
v-for="device in availableAudioDevices"
:key="device.deviceId"
class="selector"
@click.stop="
(e) => {
handleDeviceChange(device.deviceId);
micListShow = false;
}
"
>
{{ device.label }}
<div
v-if="selectedAudioDevice && device.deviceId === selectedAudioDevice.deviceId"
class="active-icon"
>
<CheckIcon />
</div>
</div>
</div>
</div>
</div>
<div class="action" @click="handleVolumeMute">
<Iconfont :icon="volumeMuted ? VolumeOff : VolumeOn" />
</div>
<div v-if="wrapperRect.width > 300">
<div class="action" @click="handleSubtitleToggle">
<Iconfont :icon="showChatRecords ? SubtitleOn : SubtitleOff" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVideoChatStore } from '@/store';
import { useVisionStore } from '@/store/vision';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import Iconfont, {
CameraOff,
CameraOn,
CheckIcon,
MicOff,
MicOn,
SubtitleOff,
SubtitleOn,
VolumeOff,
VolumeOn,
} from './Iconfont';
const videoChatStore = useVideoChatStore();
const visionStore = useVisionStore();
const {
hasCamera,
hasMic,
cameraOff,
micMuted,
volumeMuted,
showChatRecords,
streamState,
selectedAudioDevice,
selectedVideoDevice,
availableAudioDevices,
availableVideoDevices,
} = storeToRefs(videoChatStore);
const {
handleCameraOff,
handleMicMuted,
handleVolumeMute,
handleDeviceChange,
handleSubtitleToggle,
} = videoChatStore;
const { wrapperRect, isLandscape } = storeToRefs(visionStore);
const micListShow = ref(false);
const cameraListShow = ref(false);
</script>
<style lang="less" scoped>
.action-group {
border-radius: 12px;
background: rgba(88, 87, 87, 0.5);
padding: 2px;
backdrop-filter: blur(8px);
.action {
cursor: pointer;
width: 42px;
height: 42px;
border-radius: 8px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
color: #fff;
.corner {
position: absolute;
right: 0px;
bottom: 0px;
padding: 3px;
.corner-inner {
width: 6px;
height: 6px;
border-top: 3px transparent solid;
border-left: 3px transparent solid;
border-bottom: 3px #fff solid;
border-right: 3px #fff solid;
}
}
// &:hover {
// .selectors {
// display: block !important;
// }
// }
.selectors {
position: absolute;
top: 0;
left: calc(100%);
margin-left: 3px;
max-height: 150px;
&.left {
left: 0;
margin-left: -3px;
transform: translateX(-100%);
}
border-radius: 12px;
width: max-content;
overflow: hidden;
overflow: auto;
background: rgba(90, 90, 90, 0.5);
backdrop-filter: blur(8px);
.selector {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
cursor: pointer;
height: 42px;
line-height: 42px;
color: #fff;
font-size: 14px;
&:hover {
background: #67666a;
}
padding-left: 15px;
padding-right: 50px;
.active-icon {
position: absolute;
right: 10px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
top: 0;
}
}
}
}
.action:hover {
background: #67666a;
}
}
.action-group + .action-group {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { StreamState } from '@/interface/voiceChat';
import { computed, onUnmounted, watch } from 'vue';
const props = withDefaults(
defineProps<{
streamState: StreamState;
// onStartChat: any;
audioSourceCallback: () => MediaStream | null;
numBars?: number;
icon?: string;
iconButtonColor?: string;
pulseColor?: string;
waveColor?: string;
pulseScale?: number;
}>(),
{
streamState: StreamState.closed,
numBars: 16,
iconButtonColor: 'var(--color-accent)',
pulseColor: 'var(--color-accent)',
waveColor: 'var(--color-accent)',
pulseScale: 1,
},
);
const emit = defineEmits([]);
let audioContext: AudioContext;
let analyser: AnalyserNode;
let dataArray: Uint8Array;
let animationId: number;
const containerWidth = computed(() => {
return props.icon ? '128px' : `calc((var(--boxSize) + var(--gutter)) * ${props.numBars} + 80px)`;
});
watch(
() => props.streamState,
() => {
console.log(111111);
if (props.streamState === 'open') setupAudioContext();
},
{ immediate: true },
);
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (audioContext) {
audioContext.close();
}
});
function setupAudioContext() {
// @ts-ignore
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const streamSource = props.audioSourceCallback();
if (!streamSource) return;
const source = audioContext.createMediaStreamSource(streamSource);
source.connect(analyser);
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.8;
dataArray = new Uint8Array(analyser.frequencyBinCount);
updateVisualization();
}
function updateVisualization() {
analyser.getByteFrequencyData(dataArray);
// Update bars
const bars = document.querySelectorAll('.gradio-webrtc-waveContainer .gradio-webrtc-box');
for (let i = 0; i < bars.length; i++) {
const barHeight = dataArray[transformIndex(i)] / 255;
const bar = bars[i] as HTMLDivElement;
bar.style.transform = `scaleY(${Math.max(0.1, barHeight)})`;
bar.style.background = props.waveColor;
bar.style.opacity = '0.5';
}
animationId = requestAnimationFrame(updateVisualization);
}
// 声波高度从两侧向中间收拢
function transformIndex(index: number): number {
const mapping = [0, 2, 4, 6, 8, 10, 12, 14, 15, 13, 11, 9, 7, 5, 3, 1];
if (index < 0 || index >= mapping.length) {
throw new Error('Index must be between 0 and 15');
}
return mapping[index];
}
</script>
<template>
<div class="gradio-webrtc-waveContainer">
<div class="gradio-webrtc-boxContainer" :style="{ width: containerWidth }">
<template v-for="(_, index) in Array(numBars / 2)" :key="index">
<div class="gradio-webrtc-box"></div>
</template>
<div class="split-container"></div>
<template v-for="(_, index) in Array(numBars / 2)" :key="index">
<div class="gradio-webrtc-box"></div>
</template>
</div>
</div>
</template>
<style scoped lang="less">
.gradio-webrtc-waveContainer {
position: relative;
display: flex;
min-height: 100px;
max-height: 128px;
justify-content: center;
align-items: center;
}
.gradio-webrtc-boxContainer {
display: flex;
justify-content: space-between;
height: 64px;
--boxSize: 4px;
--gutter: 4px;
}
.split-container {
width: 80px;
}
.gradio-webrtc-box {
height: 100%;
width: var(--boxSize);
background: var(--color-accent);
border-radius: 8px;
transition: transform 0.05s ease;
}
.gradio-webrtc-icon-container {
position: relative;
width: 128px;
height: 128px;
display: flex;
justify-content: center;
align-items: center;
}
.gradio-webrtc-icon {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
transition: transform 0.1s ease;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
}
.icon-image {
width: 32px;
height: 32px;
object-fit: contain;
filter: brightness(0) invert(1);
}
.pulse-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
opacity: 0.5;
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(var(--max-scale, 3));
opacity: 0;
}
}
.dots {
display: flex;
gap: 8px;
align-items: center;
height: 64px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
opacity: 0.5;
animation: pulse 1.5s infinite;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
</style>

126
src/components/ChatBtn.vue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<div class="player-controls">
<div
:class="[
'chat-btn',
streamState === StreamState.closed && 'start-chat',
streamState === StreamState.open && 'stop-chat'
]"
@click="onStartChat"
>
<template v-if="streamState === StreamState.closed">
<span>点击开始对话</span>
</template>
<template v-else-if="streamState === StreamState.waiting">
<div class="waiting-icon-text">
<div class="icon" title="spinner">
<!-- <Spin wrapperClassName="spin-icon"></Spin> -->
<!-- TODO: spinner 替换 -->
</div>
<span>等待中</span>
</div>
</template>
<template v-else>
<div class="stop-chat-inner"></div>
</template>
</div>
<template v-if="streamState === StreamState.open">
<div class="input-audio-wave">
<AudioWave
:audioSourceCallback="audioSourceCallback"
:streamState="streamState"
:waveColor="waveColor"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import {Spin} from 'ant-design-vue'
import { StreamState } from '@/interface/voiceChat'
import AudioWave from '@/components/AudioWave.vue'
const props = withDefaults(
defineProps<{
streamState: StreamState
onStartChat: any
audioSourceCallback: () => MediaStream | null
waveColor: string
}>(),
{
streamState: StreamState.closed
}
)
const emit = defineEmits([])
</script>
<style scoped lang="less"></style>
<style scoped lang="less">
.player-controls {
height: 15%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 84px;
.chat-btn {
height: 64px;
width: 296px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 999px;
opacity: 1;
background: linear-gradient(180deg, #7873f6 0%, #524de1 100%);
transition: all 0.3s;
z-index: 2;
cursor: pointer;
}
.start-chat {
font-size: 16px;
font-weight: 500;
text-align: center;
color: #ffffff;
}
.waiting-icon-text {
width: 80px;
align-items: center;
font-size: 16px;
font-weight: 500;
color: #ffffff;
margin: 0 var(--spacing-sm);
display: flex;
justify-content: space-evenly;
gap: var(--size-1);
.icon {
width: 25px;
height: 25px;
fill: #ffffff;
stroke: #ffffff;
color: #ffffff;
}
}
.stop-chat {
width: 64px;
.stop-chat-inner {
width: 25px;
height: 25px;
border-radius: 6.25px;
background: #fafafa;
}
}
.input-audio-wave {
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import Iconfont, { Send } from '@/components/Iconfont';
import { insertStringAt } from '@/utils/utils';
import { useTemplateRef } from 'vue';
const props = withDefaults(
defineProps<{
replying: boolean;
}>(),
{},
);
const emit = defineEmits(['send', 'stop', 'interrupt']);
let inputHeight = 24;
let rowsDivRef = useTemplateRef<HTMLDivElement>('rowsDivRef');
let chatInputRef = useTemplateRef<HTMLInputElement>('chatInputRef');
let inputValue = '';
function on_chat_input_keydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.altKey) {
if (chatInputRef.value) {
chatInputRef.value.value = insertStringAt(
chatInputRef.value.value,
'\n',
chatInputRef.value.selectionStart || 0,
);
chatInputRef.value.dispatchEvent(new InputEvent('input'));
}
} else {
event.preventDefault();
on_send();
}
}
}
async function on_send() {
if (chatInputRef.value) {
emit('send', chatInputRef.value.value);
chatInputRef.value.value = '';
}
}
function on_chat_input(event: Event) {
if (rowsDivRef.value) {
rowsDivRef.value.textContent = (event.target as any).value.replace(/\n$/, '\n\n');
inputHeight = rowsDivRef.value.offsetHeight;
}
}
function onStop() {
emit('stop');
}
function onInterrupt() {
emit('interrupt');
}
</script>
<template>
<div class="chat-input-container">
<div class="stop-chat-btn" @click="onStop"></div>
<div class="chat-input-inner">
<div class="chat-input-wrapper">
<textarea
class="chat-input"
ref="chatInputRef"
@keydown="on_chat_input_keydown"
@input="on_chat_input"
:style="`height:${inputHeight}px`"
/>
<div class="rowsDiv" ref="rowsDivRef">{{ inputValue }}</div>
</div>
<template v-if="replying">
<button class="interrupt-btn" @click="onInterrupt"></button>
</template>
<template v-else>
<button class="send-btn" @click="on_send">
<Iconfont :icon="Send" :color="'#fff'"></Iconfont>
</button>
</template>
</div>
</div>
</template>
<style scoped lang="less">
.chat-input-container {
height: 15%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 84px;
width: calc(100% - 140px);
margin: auto;
// padding: 0 12px;
.chat-input-inner {
padding: 0 12px;
background-color: #fff;
height: 64px;
flex: 1;
display: flex;
align-items: center;
border: 1px solid #e8eaf2;
border-radius: 12px;
border-radius: 20px;
box-shadow:
0 12px 24px -16px rgba(54, 54, 73, 0.04),
0 12px 40px 0 rgba(51, 51, 71, 0.08),
0 0 1px 0 rgba(44, 44, 54, 0.02);
.chat-input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
.chat-input {
width: 100%;
border: none;
outline: none;
color: #26244c;
font-size: 16px;
font-weight: 400;
resize: none;
padding: 0;
margin: 8px 0;
line-height: 24px;
max-height: 48px;
min-height: 24px;
}
.rowsDiv {
position: absolute;
left: 0;
right: 0;
z-index: -1;
visibility: hidden;
font-size: 16px;
font-weight: 400;
line-height: 24px;
white-space: pre-wrap;
word-wrap: break-word;
}
}
.send-btn,
.interrupt-btn {
border: none;
flex: 0 0 auto;
background: #615ced;
border-radius: 20px;
height: 28px;
width: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px;
cursor: pointer;
}
.interrupt-btn {
&::after {
content: ' ';
width: 12px;
height: 12px;
border-radius: 2px;
background: #fafafa;
}
}
}
.stop-chat-btn {
cursor: pointer;
margin-right: 12px;
height: 28px;
width: 28px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 999px;
opacity: 1;
background: linear-gradient(180deg, #7873f6 0%, #524de1 100%);
&::after {
content: ' ';
width: 12px;
height: 12px;
border-radius: 2px;
background: #fafafa;
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
message: string,
role: string,
style?: string
}>(),
{}
)
</script>
<template>
<div :class="['answer-message-container', role]" :style="style">
<div class="answer-message-text">
{{ message }}
</div>
</div>
</template>
<style scoped lang="less">
.answer-message-container {
padding: 6px 12px;
background: rgba(255, 255, 255, 0.8);
border-radius: 12px;
color: #26244c;
&.human {
background: #dddddd99;
// margin-left: 20px;
margin-right: 0;
}
&.avatar {
background: #9189fa;
color: #ffffff;
// margin-right: 20px;
}
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { nextTick, useTemplateRef, watch } from "vue";
import ChatMessage from "@/components/ChatMessage.vue";
const props = defineProps<{
chatRecords: any[]
}>();
let containerRef = useTemplateRef<HTMLElement>('containerRef')
watch(() => props.chatRecords, (val) => {
if (props.chatRecords) {
nextTick().then(() => {
scrollToBottom()
})
}
})
function scrollToBottom() {
// console.log("🚀 ~ scrollToBottom ~ scrollToBottom:")
if (containerRef.value) {
containerRef.value.scrollTop = containerRef.value.scrollHeight;
}
}
defineExpose({
scrollToBottom
})
</script>
<template>
<div class="chat-records" ref="containerRef">
<div class="chat-records-inner">
<template v-for="(item, i) in chatRecords" :key="item.id">
<div :class="`chat-message ${item.role}`">
<ChatMessage :message="item.message" :role="item.role"></ChatMessage>
</div>
</template>
</div>
</div>
</template>
<style lang="less">
.chat-records {
width: 100%;
height: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
}
.chat-records-inner {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: end;
width: 100%;
// height: 100%;
height: auto;
min-height: 100%;
.chat-message {
margin-bottom: 12px;
max-width: 80%;
&.human {
align-self: flex-end;
}
&.avatar {
align-self: flex-start;
}
&:last-child {
margin-bottom: 0;
}
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_13_279">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
<clipPath id="master_svg1_13_279/13_007">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_13_279)">
<g clip-path="url(#master_svg1_13_279/13_007)">
<g>
<rect x="0" y="0" width="20" height="20" rx="0" fill="#FFFFFF" fill-opacity="0.009999999776482582"
style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M0.83317090625,15.8333259765625L0.83317090625,4.1666259765625Q0.83317090625,4.0845497765625,0.84918290625,4.0040509765625Q0.86519490625,3.9235519765625,0.89660490625,3.8477229765625Q0.92801390625,3.7718949765625,0.97361290625,3.7036509765625Q1.01921190625,3.6354069765625,1.07724790625,3.5773699765625Q1.13528490625,3.5193339765625,1.2035289062499999,3.4737349765625Q1.27177290625,3.4281359765625,1.34760090625,3.3967269765625Q1.42342990625,3.3653169765625,1.50392890625,3.3493049765625003Q1.58442770625,3.3332929765625,1.66650390625,3.3332929765625L14.99980390625,3.3332929765625Q15.08190390625,3.3332929765625,15.16240390625,3.3493049765625003Q15.24290390625,3.3653169765625,15.31870390625,3.3967269765625Q15.39460390625,3.4281359765625,15.46280390625,3.4737349765625Q15.53100390625,3.5193339765625,15.58910390625,3.5773699765625Q15.64710390625,3.6354069765625,15.69270390625,3.7036509765625Q15.73830390625,3.7718949765625,15.76970390625,3.8477229765625Q15.80110390625,3.9235519765625,15.81720390625,4.0040509765625Q15.83320390625,4.0845497765625,15.83320390625,4.1666259765625L15.83320390625,15.8333259765625Q15.83320390625,15.9153259765625,15.81720390625,15.9958259765625Q15.80110390625,16.0763259765625,15.76970390625,16.152225976562498Q15.73830390625,16.2280259765625,15.69270390625,16.2962259765625Q15.64710390625,16.3645259765625,15.58910390625,16.4225259765625Q15.53100390625,16.4806259765625,15.46280390625,16.5262259765625Q15.39460390625,16.5718259765625,15.31870390625,16.6032259765625Q15.24290390625,16.6346259765625,15.16240390625,16.6506259765625Q15.08190390625,16.6666259765625,14.99980390625,16.6666259765625L1.66650390625,16.6666259765625Q1.58442770625,16.6666259765625,1.50392890625,16.6506259765625Q1.42342990625,16.6346259765625,1.34760090625,16.6032259765625Q1.27177290625,16.5718259765625,1.2035289062499999,16.5262259765625Q1.13528490625,16.4806259765625,1.07724790625,16.4225259765625Q1.01921190625,16.3645259765625,0.97361290625,16.2962259765625Q0.92801390625,16.2280259765625,0.89660490625,16.152225976562498Q0.86519490625,16.0763259765625,0.84918290625,15.9958259765625Q0.83317090625,15.9153259765625,0.83317090625,15.8333259765625ZM2.49983690625,4.9999589765625L2.49983690625,14.9999259765625L14.16650390625,14.9999259765625L14.16650390625,4.9999589765625L2.49983690625,4.9999589765625Z"
fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M18.97024,14.7041040234375Q19.06538,14.5913440234375,19.11602,14.4527940234375Q19.16667,14.3142340234375,19.16667,14.1667040234375L19.16667,5.8333740234375Q19.16667,5.7512978234375,19.15065,5.6707990234375Q19.13464,5.5903000234375,19.10323,5.5144710234375Q19.07182,5.4386430234375,19.026220000000002,5.3703990234375Q18.98063,5.3021550234375,18.92259,5.2441180234375Q18.86455,5.1860820234375,18.79631,5.1404830234375Q18.72806,5.0948840234375,18.65224,5.0634750234375Q18.57641,5.0320650234375,18.49591,5.0160530234375Q18.41541,5.0000410234375,18.33333,5.0000410234375Q18.18581,5.0000410234375,18.04725,5.0506860234375Q17.90869,5.1013300234375,17.79594,5.1964640234375L14.462608,8.0089640234375Q14.393074,8.067634023437499,14.337838,8.1399240234375Q14.282601,8.2122140234375,14.244265,8.2947240234375Q14.205928,8.377224023437499,14.186297,8.4660640234375Q14.166667,8.5548940234375,14.166667,8.6458740234375L14.166667,11.3542040234375Q14.166667,11.4451840234375,14.186297,11.5340240234375Q14.205928,11.622854023437501,14.244265,11.7053640234375Q14.282601,11.7878640234375,14.337838,11.860154023437499Q14.393074,11.932444023437501,14.462608,11.9911140234375L17.79594,14.8036140234375Q17.922629999999998,14.9105140234375,18.08058,14.9607840234375Q18.23853,15.0110640234375,18.4037,14.9970640234375Q18.56887,14.9830640234375,18.71611,14.9069240234375Q18.86335,14.8307940234375,18.97024,14.7041040234375ZM17.5,12.3732440234375L17.5,7.6268340234375L15.833333,9.0330840234375L15.833333,10.9669940234375L17.5,12.3732440234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M7.65749209375,7.3101989765625L10.11698609375,9.3597759765625Q10.17518609375,9.4082759765625,10.22367609375,9.4664759765625Q10.32979609375,9.5938159765625,10.37910609375,9.7520659765625Q10.42841609375,9.9103259765625,10.41340609375,10.0754059765625Q10.39839609375,10.2404859765625,10.321366093750001,10.3872559765625Q10.24432609375,10.5340259765625,10.11698609375,10.6401459765625L7.65748809375,12.6897259765625Q7.54118509375,12.7998059765625,7.39241009375,12.8590459765625Q7.24363409375,12.9182959765625,7.08349609375,12.9182959765625Q7.00125579375,12.9182959765625,6.92059609375,12.9022459765625Q6.83993609375,12.8862059765625,6.76395509375,12.8547359765625Q6.6879750937499995,12.8232559765625,6.61959509375,12.7775659765625Q6.55121509375,12.731875976562499,6.49306209375,12.673725976562501Q6.43490909375,12.6155759765625,6.38921909375,12.547195976562499Q6.34352909375,12.478815976562501,6.31205709375,12.4028359765625Q6.28058509375,12.3268559765625,6.26454009375,12.2461959765625Q6.24849609375,12.1655359765625,6.24849609375,12.0832959765625Q6.24849609375,11.9848059765625,6.27141009375,11.8890159765625Q6.29432409375,11.7932359765625,6.33889509375,11.7054159765625Q6.38346609375,11.6175859765625,6.44724609375,11.5425459765625Q6.51102709375,11.4674959765625,6.59051809375,11.4093459765625L8.28178609375,9.9999559765625L6.59051809375,8.5905679765625Q6.51102709375,8.5324209765625,6.44724609375,8.4573769765625Q6.38346509375,8.3823319765625,6.33889509375,8.2945069765625Q6.29432409375,8.2066819765625,6.27141009375,8.1108979765625Q6.24849609375,8.0151131765625,6.24849609375,7.9166259765625Q6.24849609375,7.8343856765625,6.26454009375,7.7537259765625Q6.28058509375,7.6730659765625,6.31205709375,7.5970849765625Q6.34352909375,7.5211049765624995,6.38921909375,7.4527249765625Q6.43490909375,7.3843449765625,6.49306209375,7.3261919765625Q6.55121509375,7.2680389765625,6.61959509375,7.2223489765625Q6.6879750937499995,7.1766589765625,6.76395509375,7.1451869765625Q6.83993609375,7.1137149765625,6.92059609375,7.0976699765625Q7.00125579375,7.0816259765625,7.08349609375,7.0816259765625Q7.24363509375,7.0816259765625,7.39241209375,7.1408709765625Q7.54118909375,7.2001159765625005,7.65749209375,7.3101989765625Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="14.000000357627869" height="10.000000357627869" viewBox="0 0 14.000000357627869 10.000000357627869">
<g>
<path
d="M13.802466686534881,1.1380186865348816Q13.89646668653488,1.0444176865348815,13.947366686534881,0.9218876865348815Q13.998366686534881,0.7993576865348816,13.998366686534881,0.6666666865348816Q13.998366686534881,0.6011698865348816,13.98556668653488,0.5369316865348817Q13.972766686534882,0.4726936865348816,13.947666686534882,0.4121826865348816Q13.922666686534882,0.3516706865348816,13.886266686534881,0.2972126865348816Q13.849866686534881,0.2427536865348816,13.803566686534882,0.19644068653488161Q13.757266686534882,0.15012768653488162,13.702766686534881,0.11373968653488165Q13.648366686534882,0.07735168653488156,13.587866686534882,0.052286686534881555Q13.527266686534881,0.02722268653488158,13.463066686534882,0.014444686534881623Q13.398866686534882,0.0016666865348815563,13.333366686534882,0.0016666865348815563Q13.201466686534882,0.0016666865348815563,13.079566686534882,0.051981686534881555Q12.957666686534882,0.10229768653488158,12.864266686534881,0.1953146865348816L12.863066686534882,0.19413268653488158L4.624996686534882,8.392776686534882L1.1369396865348815,4.921396686534882L1.1357636865348817,4.922586686534881Q1.0422996865348817,4.829566686534881,0.9204146865348816,4.779246686534882Q0.7985286865348816,4.728936686534881,0.6666666865348816,4.728936686534881Q0.6011698865348816,4.728936686534881,0.5369316865348817,4.741706686534882Q0.4726936865348816,4.754486686534881,0.4121826865348816,4.779556686534882Q0.3516706865348816,4.804616686534882,0.2972126865348816,4.8410066865348815Q0.2427536865348816,4.8773966865348815,0.19644068653488161,4.9237066865348815Q0.15012768653488162,4.970016686534882,0.11373968653488165,5.024476686534881Q0.07735168653488156,5.078936686534882,0.052286686534881555,5.139446686534882Q0.02722268653488158,5.199956686534882,0.014444686534881623,5.2641966865348815Q0.0016666865348815563,5.328436686534881,0.0016666865348815563,5.3939366865348815Q0.0016666865348815563,5.526626686534882,0.05259268653488158,5.649156686534882Q0.10351768653488158,5.771686686534881,0.1975696865348816,5.865286686534882L0.1963936865348816,5.866466686534881L4.1547266865348815,9.805866686534882Q4.201126686534882,9.852046686534882,4.255616686534882,9.888306686534882Q4.310106686534882,9.924576686534882,4.3706166865348814,9.949556686534882Q4.431126686534881,9.974536686534881,4.495326686534882,9.987266686534882Q4.559536686534882,9.999996686534882,4.624996686534882,9.999996686534882Q4.690456686534882,9.999996686534882,4.754666686534882,9.987266686534882Q4.818876686534882,9.974536686534881,4.879386686534882,9.949556686534882Q4.939886686534882,9.924576686534882,4.994386686534882,9.888306686534882Q5.048876686534881,9.852046686534882,5.0952766865348815,9.805866686534882L13.803566686534882,1.1392006865348816L13.802466686534881,1.1380186865348816Z"
fill-rule="evenodd" fill="#E0E0FC" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</svg>
</template>

View File

@@ -0,0 +1,42 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_13_287/13_278">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
<clipPath id="master_svg1_13_287/13_278/13_040">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_13_287/13_278)">
<g clip-path="url(#master_svg1_13_287/13_278/13_040)">
<g>
<path
d="M7.34851109375,12.6516259765625Q8.44685609375,13.7499259765625,10.00016609375,13.7499259765625Q11.55346609375,13.7499259765625,12.65181609375,12.6516259765625Q13.75016609375,11.5532659765625,13.75016609375,9.9999559765625L13.75016609375,4.5832959765625Q13.75016609375,3.0299959765624997,12.65181609375,1.9316429765625Q11.55346609375,0.8332929765625,10.00016609375,0.8332919765625Q8.44685609375,0.8332929765625,7.34851109375,1.9316429765625Q6.25016309375,3.0299959765624997,6.25016309375,4.5832959765625L6.25016309375,9.9999559765625Q6.25016309375,11.5532659765625,7.34851109375,12.6516259765625ZM11.47330609375,11.4730959765625Q10.86310609375,12.0833259765625,10.00016609375,12.0833259765625Q9.13721609375,12.0833259765625,8.527026093749999,11.4730959765625Q7.91682909375,10.8629059765625,7.91682909375,9.9999559765625L7.91683009375,4.5832959765625Q7.91683009375,3.7203459765625,8.527026093749999,3.1101559765625Q9.13721609375,2.4999589765625,10.00016609375,2.4999589765625Q10.86310609375,2.4999589765625,11.47330609375,3.1101559765625Q12.08349609375,3.7203459765625,12.08349609375,4.5832959765625L12.08349609375,9.9999559765625Q12.08349609375,10.8629059765625,11.47330609375,11.4730959765625Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M17.08315046875,9.6393233234375Q17.08502046875,9.6113801234375,17.08502046875,9.5833740234375Q17.08502046875,9.5011337234375,17.06898046875,9.4204740234375Q17.05293046875,9.3398140234375,17.02146046875,9.2638330234375Q16.98999046875,9.1878530234375,16.94430046875,9.1194730234375Q16.89861046875,9.0510930234375,16.84046046875,8.9929400234375Q16.78230046875,8.9347870234375,16.71392346875,8.8890970234375Q16.64554246875,8.8434070234375,16.56956246875,8.8119350234375Q16.49358246875,8.7804630234375,16.41292246875,8.7644180234375Q16.33226246875,8.7483740234375,16.25002246875,8.7483740234375Q16.16778146875,8.7483740234375,16.08712146875,8.7644180234375Q16.00646146875,8.7804630234375,15.93048146875,8.8119350234375Q15.85450146875,8.8434070234375,15.78612106875,8.8890970234375Q15.71774076875,8.9347870234375,15.65958806875,8.9929400234375Q15.60143546875,9.0510930234375,15.55574546875,9.1194730234375Q15.51005446875,9.1878530234375,15.47858246875,9.2638330234375Q15.44711046875,9.3398140234375,15.43106646875,9.4204740234375Q15.41502246875,9.5011337234375,15.41502246875,9.5833740234375Q15.41502246875,9.6080265234375,15.41647646875,9.6326360234375Q15.40712446875,10.7164940234375,14.98582546875,11.7046140234375Q14.89498046875,11.8831040234375,14.89498046875,12.0833740234375Q14.89498046875,12.1656140234375,14.91102446875,12.2462740234375Q14.92706946875,12.3269340234375,14.95854146875,12.4029140234375Q14.99001346875,12.4788940234375,15.03570346875,12.5472740234375Q15.08139346875,12.6156540234375,15.13954646875,12.6738040234375Q15.19769946875,12.7319640234375,15.26607946875,12.7776540234375Q15.33445946875,12.8233440234375,15.41043946875,12.8548140234375Q15.48642046875,12.8862840234375,15.56708046875,12.9023340234375Q15.64774016875,12.9183740234375,15.72998046875,12.9183740234375Q15.79409136875,12.9183740234375,15.85745046875,12.9085840234375Q15.92081046875,12.8988040234375,15.98193346875,12.8794540234375Q16.04305546875,12.8601140234375,16.10050846875,12.8316640234375Q16.15796246875,12.8032140234375,16.21039846875,12.7663240234375Q16.26283546875,12.7294440234375,16.309026468749998,12.6849840234375Q16.35521746875,12.6405240234375,16.39408046875,12.5895340234375Q16.43294246875,12.538544023437499,16.46356646875,12.4822240234375Q16.49418946875,12.4258940234375,16.51585446875,12.3655540234375Q17.07244046875,11.0643540234375,17.08315046875,9.6393233234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M4.583527,9.6329521234375Q4.585,9.6081849234375,4.585,9.5833740234375Q4.585,9.5011337234375,4.568956,9.4204740234375Q4.552911,9.3398140234375,4.521439,9.2638330234375Q4.489967,9.1878530234375,4.444277,9.1194730234375Q4.398587,9.0510930234375,4.340434,8.9929400234375Q4.282281,8.9347870234375,4.213901,8.8890970234375Q4.1455210000000005,8.8434070234375,4.069541,8.8119350234375Q3.99356,8.7804630234375,3.9129,8.7644180234375Q3.8322403,8.7483740234375,3.75,8.7483740234375Q3.6677597,8.7483740234375,3.5871,8.7644180234375Q3.50644,8.7804630234375,3.430459,8.8119350234375Q3.354479,8.8434070234375,3.286099,8.8890970234375Q3.2177189999999998,8.9347870234375,3.159566,8.9929400234375Q3.101413,9.0510930234375,3.055723,9.1194730234375Q3.010033,9.1878530234375,2.978561,9.2638330234375Q2.947089,9.3398140234375,2.931044,9.4204740234375Q2.915,9.5011337234375,2.915,9.5833740234375Q2.915,9.6112012234375,2.916853,9.6389666234375Q2.9363479999999997,12.5370740234375,4.99132,14.5920540234375Q7.06598,16.6667040234375,10,16.6667040234375Q11.1917,16.6667040234375,12.30806,16.2819440234375Q12.37346,16.2636640234375,12.43505,16.235064023437502Q12.49663,16.2064640234375,12.55279,16.1682840234375Q12.60894,16.1301040234375,12.65819,16.0833540234375Q12.70744,16.036604023437498,12.74849,15.9825140234375Q12.78954,15.9284240234375,12.82131,15.868404023437499Q12.85308,15.8083940234375,12.87473,15.7440340234375Q12.89639,15.6796740234375,12.90736,15.6126640234375Q12.91833,15.5456540234375,12.91833,15.4777440234375Q12.91833,15.3955040234375,12.90229,15.3148440234375Q12.88624,15.2341840234375,12.85477,15.1582040234375Q12.8233,15.082224023437501,12.77761,15.0138440234375Q12.73192,14.9454640234375,12.67377,14.8873140234375Q12.61561,14.8291640234375,12.54723,14.783474023437499Q12.47885,14.7377840234375,12.40287,14.7063140234375Q12.32689,14.6748340234375,12.24623,14.658794023437501Q12.16557,14.6427540234375,12.08333,14.642744023437501Q11.91469,14.642744023437501,11.75926,14.7082040234375Q10.9093,15.0000440234375,10,15.0000440234375Q7.75633,15.0000440234375,6.16983,13.413544023437499Q4.6008890000000005,11.8445940234375,4.583527,9.6329521234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M10.833333,15.8861049234375Q10.835,15.8597658234375,10.835,15.8333740234375Q10.835,15.7511337234375,10.818956,15.6704740234375Q10.802911,15.5898140234375,10.771439,15.5138330234375Q10.739967,15.4378530234375,10.694277,15.3694730234375Q10.648587,15.3010930234375,10.590434,15.2429400234375Q10.532281,15.1847870234375,10.463901,15.1390970234375Q10.395521,15.0934070234375,10.319541,15.0619350234375Q10.24356,15.0304630234375,10.1629,15.0144180234375Q10.0822403,14.9983740234375,10,14.9983740234375Q9.9177597,14.9983740234375,9.8371,15.0144180234375Q9.75644,15.0304630234375,9.680459,15.0619350234375Q9.604479,15.0934070234375,9.536099,15.1390970234375Q9.467719,15.1847870234375,9.409566,15.2429400234375Q9.351413,15.3010930234375,9.305723,15.3694730234375Q9.260033,15.4378530234375,9.228561,15.5138330234375Q9.197089,15.5898140234375,9.181044,15.6704740234375Q9.165,15.7511337234375,9.165,15.8333740234375Q9.165,15.8597658234375,9.166667,15.8861049234375L9.166667,18.2806440234375Q9.165,18.3069840234375,9.165,18.3333740234375Q9.165,18.4156140234375,9.181044,18.4962740234375Q9.197089,18.5769340234375,9.228561,18.6529140234375Q9.260033,18.7288940234375,9.305723,18.7972740234375Q9.351413,18.8656540234375,9.409566,18.9238040234375Q9.467719,18.9819640234375,9.536099,19.0276540234375Q9.604479,19.0733440234375,9.680459,19.1048140234375Q9.75644,19.1362840234375,9.8371,19.1523340234375Q9.9177597,19.1683740234375,10,19.1683740234375Q10.0822403,19.1683740234375,10.1629,19.1523340234375Q10.24356,19.1362840234375,10.319541,19.1048140234375Q10.395521,19.0733440234375,10.463901,19.0276540234375Q10.532281,18.9819640234375,10.590434,18.9238040234375Q10.648587,18.8656540234375,10.694277,18.7972740234375Q10.739967,18.7288940234375,10.771439,18.6529140234375Q10.802911,18.5769340234375,10.818956,18.4962740234375Q10.835,18.4156140234375,10.835,18.3333740234375Q10.835,18.3069840234375,10.833333,18.2806440234375L10.833333,15.8861049234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M1.9480309999999998,3.126542Q1.813081,3.007654,1.7390400000000001,2.843752Q1.665,2.67985,1.665,2.5Q1.665,2.4177597,1.681044,2.3371Q1.697089,2.25644,1.728561,2.180459Q1.760033,2.104479,1.805723,2.036099Q1.851413,1.967719,1.9095659999999999,1.9095659999999999Q1.967719,1.851413,2.036099,1.805723Q2.104479,1.760033,2.180459,1.728561Q2.25644,1.697089,2.3371,1.681044Q2.4177597,1.665,2.5,1.665Q2.67985,1.665,2.843752,1.7390400000000001Q3.007654,1.813081,3.126542,1.9480309999999998L18.052,16.8735Q18.1869,16.9923,18.261,17.1562Q18.335,17.3202,18.335,17.5Q18.335,17.5822,18.319000000000003,17.6629Q18.3029,17.7436,18.2714,17.819499999999998Q18.240000000000002,17.8955,18.1943,17.963900000000002Q18.148600000000002,18.0323,18.090400000000002,18.090400000000002Q18.0323,18.148600000000002,17.963900000000002,18.1943Q17.8955,18.240000000000002,17.819499999999998,18.2714Q17.7436,18.3029,17.6629,18.319000000000003Q17.5822,18.335,17.5,18.335Q17.3202,18.335,17.1562,18.261Q16.9923,18.1869,16.8735,18.052L1.9480309999999998,3.126542Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,36 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_13_278">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
<clipPath id="master_svg1_13_278/13_029">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_13_278)">
<g clip-path="url(#master_svg1_13_278/13_029)">
<g>
<rect x="0" y="0" width="20" height="20" rx="0" fill="#FFFFFF" fill-opacity="0.009999999776482582"
style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M6.249918953125,9.9999559765625L6.249918953125,4.5832959765625Q6.249918953125,3.0299959765624997,7.348267953125,1.9316419765625Q8.446621953125,0.8332929765625,9.999921953125,0.8332929765625Q11.553221953125,0.8332929765625,12.651571953125,1.9316419765625Q13.749921953125,3.0299959765624997,13.749921953125,4.5832959765625L13.749921953125,9.9999559765625Q13.749921953125,11.5532559765625,12.651571953125,12.6516259765625Q11.553221953125,13.7499259765625,9.999921953125,13.7499259765625Q8.446621953125,13.7499259765625,7.348267953125,12.6516259765625Q6.249918953125,11.5532559765625,6.249918953125,9.9999559765625ZM7.916584953125,9.9999559765625Q7.916584953125,10.8629059765625,8.526781953124999,11.4730959765625Q9.136971953125,12.0833259765625,9.999921953125,12.0833259765625Q10.862861953125,12.0833259765625,11.473061953125,11.4730959765625Q12.083251953125,10.8629059765625,12.083251953125,9.9999559765625L12.083251953125,4.5832959765625Q12.083251953125,3.7203459765625,11.473061953125,3.1101559765625Q10.862861953125,2.4999589765625,9.999921953125,2.4999589765625Q9.136971953125,2.4999589765625,8.526781953124999,3.1101559765625Q7.916584953125,3.7203459765625,7.916584953125,4.5832959765625L7.916584953125,9.9999559765625Z"
fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M4.583527,9.6329521234375Q4.585,9.6081849234375,4.585,9.5833740234375Q4.585,9.5011337234375,4.568956,9.4204740234375Q4.552911,9.3398140234375,4.521439,9.2638330234375Q4.489967,9.1878530234375,4.444277,9.1194730234375Q4.398587,9.0510930234375,4.340434,8.9929400234375Q4.282281,8.9347870234375,4.213901,8.8890970234375Q4.1455210000000005,8.8434070234375,4.069541,8.8119350234375Q3.99356,8.7804630234375,3.9129,8.7644180234375Q3.8322403,8.7483740234375,3.75,8.7483740234375Q3.6677597,8.7483740234375,3.5871,8.7644180234375Q3.50644,8.7804630234375,3.430459,8.8119350234375Q3.354479,8.8434070234375,3.286099,8.8890970234375Q3.2177189999999998,8.9347870234375,3.159566,8.9929400234375Q3.101413,9.0510930234375,3.055723,9.1194730234375Q3.010033,9.1878530234375,2.978561,9.2638330234375Q2.947089,9.3398140234375,2.931044,9.4204740234375Q2.915,9.5011337234375,2.915,9.5833740234375Q2.915,9.6112012234375,2.916853,9.6389666234375Q2.9363479999999997,12.5370740234375,4.99132,14.5920540234375Q7.06598,16.6667040234375,10,16.6667040234375Q12.93402,16.6667040234375,15.0087,14.5920540234375Q17.0636,12.5370940234375,17.0831,9.6390003234375Q17.085,9.6112181234375,17.085,9.5833740234375Q17.085,9.5011337234375,17.069000000000003,9.4204740234375Q17.0529,9.3398140234375,17.0214,9.2638330234375Q16.990000000000002,9.1878530234375,16.9443,9.1194730234375Q16.898600000000002,9.0510930234375,16.840400000000002,8.9929400234375Q16.7823,8.9347870234375,16.713900000000002,8.8890970234375Q16.6455,8.8434070234375,16.569499999999998,8.8119350234375Q16.4936,8.7804630234375,16.4129,8.7644180234375Q16.3322,8.7483740234375,16.25,8.7483740234375Q16.1678,8.7483740234375,16.0871,8.7644180234375Q16.0064,8.7804630234375,15.9305,8.8119350234375Q15.8545,8.8434070234375,15.7861,8.8890970234375Q15.7177,8.9347870234375,15.6596,8.9929400234375Q15.6014,9.0510930234375,15.5557,9.1194730234375Q15.51,9.1878530234375,15.4786,9.2638330234375Q15.4471,9.3398140234375,15.431,9.4204740234375Q15.415,9.5011337234375,15.415,9.5833740234375Q15.415,9.6081817234375,15.4165,9.6329456234375Q15.3991,11.8445940234375,13.8302,13.413544023437499Q12.24366,15.0000440234375,10,15.0000440234375Q7.75633,15.0000440234375,6.16983,13.413544023437499Q4.6008890000000005,11.8445940234375,4.583527,9.6329521234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M10.833333,15.8861049234375Q10.835,15.8597658234375,10.835,15.8333740234375Q10.835,15.7511337234375,10.818956,15.6704740234375Q10.802911,15.5898140234375,10.771439,15.5138330234375Q10.739967,15.4378530234375,10.694277,15.3694730234375Q10.648587,15.3010930234375,10.590434,15.2429400234375Q10.532281,15.1847870234375,10.463901,15.1390970234375Q10.395521,15.0934070234375,10.319541,15.0619350234375Q10.24356,15.0304630234375,10.1629,15.0144180234375Q10.0822403,14.9983740234375,10,14.9983740234375Q9.9177597,14.9983740234375,9.8371,15.0144180234375Q9.75644,15.0304630234375,9.680459,15.0619350234375Q9.604479,15.0934070234375,9.536099,15.1390970234375Q9.467719,15.1847870234375,9.409566,15.2429400234375Q9.351413,15.3010930234375,9.305723,15.3694730234375Q9.260033,15.4378530234375,9.228561,15.5138330234375Q9.197089,15.5898140234375,9.181044,15.6704740234375Q9.165,15.7511337234375,9.165,15.8333740234375Q9.165,15.8597658234375,9.166667,15.8861049234375L9.166667,18.2806440234375Q9.165,18.3069840234375,9.165,18.3333740234375Q9.165,18.4156140234375,9.181044,18.4962740234375Q9.197089,18.5769340234375,9.228561,18.6529140234375Q9.260033,18.7288940234375,9.305723,18.7972740234375Q9.351413,18.8656540234375,9.409566,18.9238040234375Q9.467719,18.9819640234375,9.536099,19.0276540234375Q9.604479,19.0733440234375,9.680459,19.1048140234375Q9.75644,19.1362840234375,9.8371,19.1523340234375Q9.9177597,19.1683740234375,10,19.1683740234375Q10.0822403,19.1683740234375,10.1629,19.1523340234375Q10.24356,19.1362840234375,10.319541,19.1048140234375Q10.395521,19.0733440234375,10.463901,19.0276540234375Q10.532281,18.9819640234375,10.590434,18.9238040234375Q10.648587,18.8656540234375,10.694277,18.7972740234375Q10.739967,18.7288940234375,10.771439,18.6529140234375Q10.802911,18.5769340234375,10.818956,18.4962740234375Q10.835,18.4156140234375,10.835,18.3333740234375Q10.835,18.3069840234375,10.833333,18.2806440234375L10.833333,15.8861049234375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</g>
</g>
</svg>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
<template>
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M899.925333 172.080762a48.761905 48.761905 0 0 1 0 28.525714l-207.969523 679.448381a48.761905 48.761905 0 0 1-81.115429 20.187429l-150.552381-150.552381-96.304762 96.329143a24.380952 24.380952 0 0 1-41.593905-17.237334v-214.966857l275.821715-243.370667-355.57181 161.596953-103.253333-103.228953a48.761905 48.761905 0 0 1 20.23619-81.091047L838.997333 139.702857a48.761905 48.761905 0 0 1 60.903619 32.353524z">
</path>
</svg>
</template>

View File

@@ -0,0 +1,27 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_13_533/13_323">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_13_533/13_323)">
<g>
<path
d="M7.5,5.0016259765625Q7.58224,5.0016259765625,7.6629,4.9855819765625Q7.74356,4.9695369765625,7.81954,4.9380649765625Q7.89552,4.9065929765625,7.9639,4.8609029765625Q8.03228,4.8152129765625,8.09043,4.7570599765625Q8.14859,4.6989069765625,8.19428,4.6305269765625Q8.23997,4.5621469765625005,8.27144,4.4861669765625Q8.30291,4.4101859765625,8.318950000000001,4.3295259765625Q8.335,4.2488662765625,8.335,4.1666259765625Q8.335,4.0843856765625,8.31896,4.0037259765625Q8.30291,3.9230659765625,8.27144,3.8470849765625Q8.23997,3.7711049765625,8.19428,3.7027249765625Q8.14859,3.6343449765624998,8.09043,3.5761919765625Q8.03228,3.5180389765625,7.9639,3.4723489765625Q7.89552,3.4266589765625,7.81954,3.3951869765625Q7.74356,3.3637149765625,7.6629,3.3476699765625Q7.58224,3.3316259765625,7.5,3.3316259765625Q7.47361,3.3316259765625,7.44727,3.3332929765625L4.16667,3.3332929765625Q3.131133,3.3332929765625,2.3989,4.0655259765625Q1.666667,4.7977589765625,1.666667,5.8332959765625L1.666667,14.1666259765625Q1.666667,15.2021259765625,2.3989,15.9344259765625Q3.131133,16.6666259765625,4.16667,16.6666259765625L7.44728,16.6666259765625Q7.47361,16.6683259765625,7.5,16.6683259765625Q7.58224,16.6683259765625,7.6629,16.652225976562498Q7.74356,16.6362259765625,7.81954,16.6047259765625Q7.89552,16.573225976562497,7.9639,16.5275259765625Q8.03228,16.4819259765625,8.09043,16.4237259765625Q8.14859,16.365525976562502,8.19428,16.2972259765625Q8.23997,16.2288259765625,8.27144,16.1528259765625Q8.30291,16.0768259765625,8.318950000000001,15.9962259765625Q8.335,15.9155259765625,8.335,15.8333259765625Q8.335,15.7510259765625,8.31896,15.6704259765625Q8.30291,15.5897259765625,8.27144,15.5137259765625Q8.23997,15.4377259765625,8.19428,15.3694259765625Q8.14859,15.3010259765625,8.09043,15.2428259765625Q8.03228,15.1847259765625,7.9639,15.1390259765625Q7.89552,15.0933259765625,7.81954,15.0618259765625Q7.74356,15.0304259765625,7.6629,15.0143259765625Q7.58224,14.9983259765625,7.5,14.9983259765625Q7.47361,14.9983259765625,7.44728,14.9999259765625L4.16667,14.9999259765625Q3.82149,14.9999259765625,3.57741,14.7559259765625Q3.333333,14.5118259765625,3.333333,14.1666259765625L3.333333,5.8332959765625Q3.333333,5.4881159765625,3.57741,5.2440359765625Q3.82149,4.9999589765625,4.16667,4.9999589765625L7.44727,4.9999589765625Q7.47361,5.0016259765625,7.5,5.0016259765625Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" />
</g>
<g>
<path
d="M12.55273,4.9999589765625Q12.5263913,5.0016259765625,12.5,5.0016259765625Q12.4177597,5.0016259765625,12.3371,4.9855819765625Q12.25644,4.9695369765625,12.180459,4.9380649765625Q12.104479,4.9065929765625,12.036099,4.8609029765625Q11.967719,4.8152129765625,11.909566,4.7570599765625Q11.851413,4.6989069765625,11.805723,4.6305269765625Q11.760033,4.5621469765625005,11.728561,4.4861669765625Q11.697089,4.4101859765625,11.681044,4.3295259765625Q11.665,4.2488662765625,11.665,4.1666259765625Q11.665,4.0843856765625,11.681044,4.0037259765625Q11.697089,3.9230659765625,11.728561,3.8470849765625Q11.760033,3.7711049765625,11.805723,3.7027249765625Q11.851413,3.6343449765624998,11.909566,3.5761919765625Q11.967719,3.5180389765625,12.036099,3.4723489765625Q12.104479,3.4266589765625,12.180459,3.3951869765625Q12.25644,3.3637149765625,12.3371,3.3476699765625Q12.4177597,3.3316259765625,12.5,3.3316259765625Q12.5263913,3.3316259765625,12.55273,3.3332929765625L15.83333,3.3332929765625Q16.86887,3.3332929765625,17.6011,4.0655259765625Q18.33333,4.7977589765625,18.33333,5.8332959765625L18.33333,14.1666259765625Q18.33333,15.2021259765625,17.6011,15.9344259765625Q16.86887,16.6666259765625,15.83333,16.6666259765625L12.5527215,16.6666259765625Q12.5263871,16.6683259765625,12.5,16.6683259765625Q12.4177597,16.6683259765625,12.3371,16.652225976562498Q12.25644,16.6362259765625,12.180459,16.6047259765625Q12.104479,16.573225976562497,12.036099,16.5275259765625Q11.967719,16.4819259765625,11.909566,16.4237259765625Q11.851413,16.365525976562502,11.805723,16.2972259765625Q11.760033,16.2288259765625,11.728561,16.1528259765625Q11.697089,16.0768259765625,11.681044,15.9962259765625Q11.665,15.9155259765625,11.665,15.8333259765625Q11.665,15.7510259765625,11.681044,15.6704259765625Q11.697089,15.5897259765625,11.728561,15.5137259765625Q11.760033,15.4377259765625,11.805723,15.3694259765625Q11.851413,15.3010259765625,11.909566,15.2428259765625Q11.967719,15.1847259765625,12.036099,15.1390259765625Q12.104479,15.0933259765625,12.180459,15.0618259765625Q12.25644,15.0304259765625,12.3371,15.0143259765625Q12.4177597,14.9983259765625,12.5,14.9983259765625Q12.5263871,14.9983259765625,12.5527215,14.9999259765625L15.83333,14.9999259765625Q16.17851,14.9999259765625,16.42259,14.7559259765625Q16.66667,14.5118259765625,16.66667,14.1666259765625L16.66667,5.8332959765625Q16.66667,5.4881159765625,16.42259,5.2440359765625Q16.17851,4.9999589765625,15.83333,4.9999589765625L12.55273,4.9999589765625Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" />
</g>
<g>
<path
d="M10.833333,2.5527319Q10.835,2.5263923,10.835,2.5Q10.835,2.4177597,10.818956,2.3371Q10.802911,2.25644,10.771439,2.180459Q10.739967,2.104479,10.694277,2.036099Q10.648587,1.967719,10.590434,1.9095659999999999Q10.532281,1.851413,10.463901,1.805723Q10.395521,1.760033,10.319541,1.728561Q10.24356,1.697089,10.1629,1.681044Q10.0822403,1.665,10,1.665Q9.9177597,1.665,9.8371,1.681044Q9.75644,1.697089,9.680459,1.728561Q9.604479,1.760033,9.536099,1.805723Q9.467719,1.851413,9.409566,1.9095659999999999Q9.351413,1.967719,9.305723,2.036099Q9.260033,2.104479,9.228561,2.180459Q9.197089,2.25644,9.181044,2.3371Q9.165,2.4177597,9.165,2.5Q9.165,2.5263923,9.166667,2.5527319L9.166667,17.4473Q9.165,17.473599999999998,9.165,17.5Q9.165,17.5822,9.181044,17.6629Q9.197089,17.7436,9.228561,17.819499999999998Q9.260033,17.8955,9.305723,17.963900000000002Q9.351413,18.0323,9.409566,18.090400000000002Q9.467719,18.148600000000002,9.536099,18.1943Q9.604479,18.240000000000002,9.680459,18.2714Q9.75644,18.3029,9.8371,18.319000000000003Q9.9177597,18.335,10,18.335Q10.0822403,18.335,10.1629,18.319000000000003Q10.24356,18.3029,10.319541,18.2714Q10.395521,18.240000000000002,10.463901,18.1943Q10.532281,18.148600000000002,10.590434,18.090400000000002Q10.648587,18.0323,10.694277,17.963900000000002Q10.739967,17.8955,10.771439,17.819499999999998Q10.802911,17.7436,10.818956,17.6629Q10.835,17.5822,10.835,17.5Q10.835,17.473599999999998,10.833333,17.4473L10.833333,2.5527319Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" />
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<svg t="1742449891206" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="2067" xmlns:xlink="http://www.w3.org/1999/xlink">
<path
d="M950.857143 109.714286l0 804.571429q0 14.857143-10.857143 25.714286t-25.714286 10.857143l-804.571429 0q-14.857143 0-25.714286-10.857143t-10.857143-25.714286l0-804.571429q0-14.857143 10.857143-25.714286t25.714286-10.857143l804.571429 0q14.857143 0 25.714286 10.857143t10.857143 25.714286z"
p-id="2068"></path>
</svg>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<svg t="1744352112173" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="16533" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path
d="M824 466.56V213.12q0-13.6512-5.2928-26.1632-5.104-12.064-14.3904-21.3536-9.2864-9.2864-21.3504-14.3872-12.5152-5.2928-26.1664-5.2928H246.4q-13.6512 0-26.1664 5.2928-12.064 5.1008-21.3504 14.3872-9.2864 9.2864-14.3904 21.3536Q179.2 199.4688 179.2 213.12v607.296q0 12.8448 5.0592 24.608 4.8576 11.2896 13.6704 19.9552 8.7616 8.6176 20.1184 13.344Q229.7792 883.2 242.56 883.2h217.6a28.8 28.8 0 0 0 0-57.6h-217.6q-2.528 0-4.2432-1.6864-1.5168-1.4912-1.5168-3.4976V213.12q0-3.9744 2.8128-6.784 2.8096-2.816 6.7872-2.816h510.4q3.9776 0 6.7872 2.816 2.8128 2.8096 2.8128 6.784v253.44a28.8 28.8 0 0 0 28.8 28.8 28.8 28.8 0 0 0 28.8-28.8zM466.0064 338.08l-130.2016 278.784A32 32 0 0 0 364.8 662.4h0.176a31.9904 31.9904 0 0 0 28.8192-18.4576L418.048 592h165.4976l15.2896 32.736q3.1008-3.4144 6.3904-6.704 20.3584-20.3616 45.4816-33.472l-115.1168-246.4832q-4.9408-10.5792-14.8704-16.5952-9.168-5.5552-19.9232-5.5552-10.7552 0-19.9232 5.5552-9.9296 6.016-14.8704 16.5952z m34.7936 76.7456L553.6576 528h-105.7152l52.8576-113.1776zM896 750.4c0 87.4816-70.9184 158.4-158.4 158.4S579.2 837.8816 579.2 750.4s70.9184-158.4 158.4-158.4 158.4 70.9184 158.4 158.4z m-116.3648-82.7648a28.9152 28.9152 0 0 1 7.1648-5.232q-5.8048-3.232-12.096-5.7248Q756.8256 649.6 737.6 649.6q-19.2256 0-37.104 7.0784-19.4112 7.6832-34.1728 22.448-14.7616 14.7616-22.4448 34.1696Q636.8 731.1744 636.8 750.4q0 19.232 7.0784 37.104 2.4896 6.2944 5.7248 12.096a28.7552 28.7552 0 0 1 5.232-7.1648l124.8-124.8zM838.4 750.4q0-19.2256-7.0784-37.104-2.4896-6.2912-5.7248-12.096a28.6944 28.6944 0 0 1-5.232 7.168l-124.8 124.8a28.7552 28.7552 0 0 1-7.1648 5.2288q5.8048 3.2352 12.096 5.728Q718.3744 851.2 737.6 851.2q19.2256 0 37.104-7.072 19.4112-7.6896 34.1728-22.4512 14.7616-14.7616 22.4448-34.1728Q838.4 769.632 838.4 750.4z"
p-id="16534"></path>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg t="1744352097285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="16380" data-spm-anchor-id="a313x.manage_type_myprojects.0.i0.60b03a81nz0mun"
xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path
d="M833.6 213.12v253.44a28.8 28.8 0 0 1-28.8 28.8 28.8 28.8 0 0 1-28.8-28.8V213.12q0-3.9744-2.8128-6.784-2.8096-2.816-6.7872-2.816H256q-3.9776 0-6.7872 2.816Q246.4 209.1424 246.4 213.12v607.296q0 2.0064 1.5168 3.4976 1.7152 1.6864 4.2432 1.6864h217.6a28.8 28.8 0 0 1 0 57.6h-217.6q-12.7808 0-24.512-4.8768-11.3568-4.7232-20.1184-13.3408-8.8128-8.6656-13.6704-19.9584Q188.8 833.264 188.8 820.416V213.12q0-13.6512 5.2928-26.1632 5.104-12.064 14.3904-21.3536 9.2864-9.2864 21.3504-14.3872Q242.3456 145.92 256 145.92h510.4q13.6512 0 26.1664 5.2928 12.064 5.1008 21.3504 14.3872 9.2864 9.2864 14.3904 21.3536 5.2928 12.512 5.2928 26.1664zM345.408 613.664l130.1984-278.784q4.9408-10.5824 14.8704-16.5984 9.168-5.5552 19.9232-5.5552 10.7552 0 19.9232 5.5552 9.9296 6.016 14.8704 16.5952l130.2016 278.784a32 32 0 0 1-28.672 45.5392l-0.3232 0.0032c-12.4288 0-23.7344-7.2-28.9952-18.4608L593.1488 588.8h-165.4976l-24.256 51.9424a32.0064 32.0064 0 0 1-28.8192 18.4576l-0.176 0.0032a32 32 0 0 1-28.992-45.5424z m164.992-202.0416L457.5424 524.8h105.7152L510.4 411.6224z m120.2784 329.44l61.3216 61.5872 162.1248-162.8256a31.9936 31.9936 0 0 1 22.608-9.424H876.8a32 32 0 0 1 32 31.936v0.064a32 32 0 0 1-9.3216 22.5792l-184.8 185.6-0.0992 0.0992a31.9936 31.9936 0 0 1-45.2544-0.096l-83.984-84.352-0.016-0.016a31.9904 31.9904 0 0 1-9.2896-21.1104l30.496-33.4336c0.4896-0.0224 0.9792-0.032 1.4688-0.032h0.0704a32 32 0 0 1 22.608 9.4208z"
p-id="16381" data-spm-anchor-id="a313x.manage_type_myprojects.0.i1.60b03a81nz0mun" class="selected"></path>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_20_113">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_20_113)">
<g>
<path
d="M17.52452171875,9.078936578124999Q17.659471718749998,8.960048578125,17.73351171875,8.796145578125Q17.80755171875,8.632242578125,17.80755171875,8.452392578125Q17.80755171875,8.370152278125,17.79151171875,8.289492578125Q17.77546171875,8.208832578125,17.74399171875,8.132851578125Q17.71252171875,8.056871578125,17.66683171875,7.988491578125Q17.62114171875,7.920111578125,17.56299171875,7.861958578125Q17.50483171875,7.803805578125,17.43645171875,7.758115578125Q17.36807171875,7.712425578125,17.29209171875,7.680953578125Q17.21611171875,7.649481578125,17.13545171875,7.633436578125Q17.05479171875,7.617392578125,16.97255171875,7.617392578125Q16.79270171875,7.617392578125,16.62880171875,7.691433578125Q16.46490171875,7.765474578125,16.34601171875,7.900425578125L12.88504271875,11.361392578124999Q12.75009271875,11.480282578125,12.67605171875,11.644182578125001Q12.60201171875,11.808082578125,12.60201171875,11.987932578125001Q12.60201171875,12.070172578125,12.61805571875,12.150832578125Q12.63410071875,12.231492578125,12.66557271875,12.307472578125001Q12.69704471875,12.383452578125,12.74273471875,12.451832578125Q12.78842471875,12.520212578125001,12.84657771875,12.578362578124999Q12.90473071875,12.636522578125,12.97311071875,12.682212578125Q13.04149071875,12.727902578125,13.11747071875,12.759372578125Q13.19345171875,12.790842578125,13.27411171875,12.806892578125Q13.35477141875,12.822932578125,13.43701171875,12.822932578125Q13.61685971875,12.822932578125,13.78076071875,12.748892578125Q13.94466171875,12.674852578125,14.06354971875,12.539902578125L17.52452171875,9.078936578124999Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M12.88553,9.078933578125Q12.75058,8.960045578125,12.67654,8.796143578125Q12.6025,8.632241578125,12.6025,8.452392578125Q12.6025,8.370152278125,12.618544,8.289492578125Q12.634589,8.208832578125,12.666061,8.132851578125Q12.697533,8.056871578125,12.743223,7.988491578125Q12.788913,7.920111578125,12.847066,7.861958578125Q12.905219,7.803805578125,12.973599,7.758115578125Q13.041979,7.712425578125,13.117959,7.680953578125Q13.19394,7.649481578125,13.2746,7.633436578125Q13.3552597,7.617392578125,13.4375,7.617392578125Q13.617349,7.617392578125,13.781251,7.691432578125Q13.945153,7.765472578125,14.064041,7.900422578125L17.52501,11.361392578124999Q17.659959999999998,11.480282578125,17.734,11.644182578125001Q17.80804,11.808082578125,17.80804,11.987932578125001Q17.80804,12.070172578125,17.792,12.150832578125Q17.77595,12.231492578125,17.74448,12.307472578125001Q17.71301,12.383452578125,17.66732,12.451832578125Q17.62163,12.520212578125001,17.56347,12.578362578124999Q17.50532,12.636522578125,17.43694,12.682212578125Q17.36856,12.727902578125,17.29258,12.759372578125Q17.2166,12.790842578125,17.13594,12.806892578125Q17.05528,12.822932578125,16.97304,12.822932578125Q16.79319,12.822932578125,16.62929,12.748892578125Q16.46539,12.674852578125,16.3465,12.539902578125L12.88553,9.078933578125Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M4.44364390625,5.42117L2.49983690625,5.42117Q1.80948090625,5.42117,1.32132890625,5.90931Q0.83317090625,6.39747,0.83317090625,7.08783L0.83317090625,12.8496Q0.83317090625,13.54,1.32132990625,14.0281Q1.80948090625,14.5163,2.49983690625,14.5163L4.43961390625,14.5163Q6.77175390625,18.3333,9.99983390625,18.3333Q10.08191390625,18.3333,10.16241390625,18.3173Q10.24291390625,18.301299999999998,10.31874390625,18.2699Q10.39456390625,18.238500000000002,10.46281390625,18.1929Q10.53105390625,18.1473,10.58909390625,18.0893Q10.64713390625,18.0312,10.69272390625,17.963Q10.73832390625,17.8947,10.76973390625,17.8189Q10.80114390625,17.7431,10.81715390625,17.662599999999998Q10.83317390625,17.5821,10.83317390625,17.5L10.83317390625,2.5Q10.83317390625,2.4179238,10.81715390625,2.337425Q10.80114390625,2.256926,10.76973390625,2.181097Q10.73832390625,2.105269,10.69272390625,2.037025Q10.64712390625,1.968781,10.58909390625,1.910744Q10.53105390625,1.852708,10.46281390625,1.807109Q10.39456390625,1.76151,10.31874390625,1.7301009999999999Q10.24291390625,1.698691,10.16241390625,1.682679Q10.08191390625,1.666667,9.99983390625,1.666667Q6.77619390625,1.666667,4.44364390625,5.42117ZM4.91587390625,7.08783Q5.02559390625,7.08783,5.13157390625,7.05943Q5.23755390625,7.03103,5.3325739062499995,6.97617Q5.42758390625,6.92131,5.50516390625,6.84372Q5.58274390625,6.76614,5.63759390625,6.67111Q7.22859390625,3.91495,9.16650390625,3.434681L9.16650390625,16.563299999999998Q7.23188390625,16.074199999999998,5.6405439062500005,13.2715Q5.58600390625,13.1754,5.50830390625,13.0969Q5.4306139062500005,13.0184,5.33514390625,12.9628Q5.23966390625,12.9072,5.1330139062499995,12.8784Q5.02635390625,12.8496,4.91587390625,12.8496L2.49983690625,12.8496L2.49983790625,7.08783L4.91587390625,7.08783Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,36 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"
width="20" height="20" viewBox="0 0 20 20">
<defs>
<clipPath id="master_svg0_13_280">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
<clipPath id="master_svg1_13_280/13_053">
<rect x="0" y="0" width="20" height="20" rx="0" />
</clipPath>
</defs>
<g clip-path="url(#master_svg0_13_280)">
<g clip-path="url(#master_svg1_13_280/13_053)">
<g>
<rect x="0" y="0" width="20" height="20" rx="0" fill="#FFFFFF" fill-opacity="0.009999999776482582"
style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M4.443888046875,5.42117L2.500081046875,5.42117Q1.809725046875,5.42117,1.321573046875,5.90931Q0.833415046875,6.39747,0.833415046875,7.08783L0.833415046875,12.8496Q0.833415046875,13.54,1.321574046875,14.0281Q1.809725046875,14.5163,2.500081046875,14.5163L4.439858046875,14.5163Q6.771998046875,18.3333,10.000078046875,18.3333Q10.082158046875,18.3333,10.162658046875,18.3173Q10.243158046875,18.301299999999998,10.318988046875,18.2699Q10.394808046875,18.238500000000002,10.463058046875,18.1929Q10.531298046875,18.1473,10.589338046875,18.0893Q10.647378046875,18.0312,10.692968046875,17.963Q10.738568046875,17.8947,10.769978046875,17.8189Q10.801388046875,17.7431,10.817398046875,17.662599999999998Q10.833418046875,17.5821,10.833418046875,17.5L10.833418046875,2.5Q10.833418046875,2.4179238,10.817398046875,2.337425Q10.801388046875,2.256926,10.769978046875,2.181097Q10.738568046875,2.105269,10.692968046875,2.037025Q10.647368046875,1.968781,10.589338046875,1.910744Q10.531298046875,1.852708,10.463058046875,1.807109Q10.394808046875,1.76151,10.318988046875,1.7301009999999999Q10.243158046875,1.698691,10.162658046875,1.682679Q10.082158046875,1.666667,10.000078046875,1.666667Q6.776438046875,1.666667,4.443888046875,5.42117ZM4.916118046875,7.08783Q5.025838046875,7.08783,5.131818046875,7.05943Q5.237798046875,7.03103,5.3328180468749995,6.97617Q5.427828046875,6.92131,5.505408046875,6.84372Q5.582988046875,6.76614,5.637838046875,6.67111Q7.228838046875,3.91495,9.166748046875,3.434681L9.166748046875,16.563299999999998Q7.232128046875,16.074199999999998,5.6407880468750005,13.2715Q5.586248046875,13.1754,5.508548046875,13.0969Q5.4308580468750005,13.0184,5.335388046875,12.9628Q5.239908046875,12.9072,5.1332580468749995,12.8784Q5.026598046875,12.8496,4.916118046875,12.8496L2.500081046875,12.8496L2.500082046875,7.08783L4.916118046875,7.08783Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M12.813896953124999,6.903831Q12.740067953125,6.845187,12.681175953125,6.771557Q12.622282953125,6.697926,12.581291953125,6.613017Q12.540300953125,6.528109,12.519276953125,6.436197Q12.498251953125,6.3442856,12.498251953125,6.25Q12.498251953125,6.1677597,12.514295953125,6.0871Q12.530340953125,6.00644,12.561812953125,5.930459Q12.593284953125,5.8544789999999995,12.638974953125,5.786099Q12.684664953125,5.717719,12.742817953125,5.659566Q12.800970953125,5.601413,12.869350953125,5.555723Q12.937730953125,5.510033,13.013710953125,5.478561Q13.089691953125,5.447089,13.170351953125,5.431044Q13.251011653125,5.415,13.333251953125,5.415Q13.501904953125,5.415,13.657335953125,5.4804580000000005Q13.812766953125,5.545916,13.930607953125,5.66657Q14.362131953125001,6.059567,14.707911953125,6.532997Q15.248111953125001,7.2726299999999995,15.535961953125,8.14354Q15.833251953125,9.04304,15.833251953125,10Q15.833251953125,10.94869,15.540941953125,11.84127Q15.257921953125,12.70551,14.726221953125,13.4418Q14.373671953125,13.92992,13.930609953125,14.33343Q13.812768953125,14.45408,13.657336953125,14.51954Q13.501904953125,14.585,13.333251953125,14.585Q13.251011653125,14.585,13.170351953125,14.56895Q13.089691953125,14.55291,13.013710953125,14.52144Q12.937730953125,14.48997,12.869350953125,14.44428Q12.800970953125,14.39859,12.742817953125,14.34043Q12.684664953125,14.28228,12.638974953125,14.213899999999999Q12.593284953125,14.145520000000001,12.561812953125,14.06954Q12.530340953125,13.99356,12.514295953125,13.9129Q12.498251953125,13.832239999999999,12.498251953125,13.75Q12.498251953125,13.655719999999999,12.519276953125,13.5638Q12.540300953125,13.47189,12.581291953125,13.386980000000001Q12.622282953125,13.30207,12.681174953125,13.228439999999999Q12.740067953125,13.154810000000001,12.813895953125,13.09617Q13.125969953125,12.8109,13.375114153125,12.46595Q14.166584953125,11.36993,14.166584953125,10Q14.166584953125,8.61762,13.362005753125,7.516Q13.117749953125,7.181583,12.813896953124999,6.903831Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
<g>
<path
d="M14.863105578125,2.228405984375Q16.823672578125,3.456592984375,17.969842578125,5.468508984375Q19.166602578125,7.569218984375,19.166602578125,10.000018984375Q19.166602578125,12.469708984375,17.933552578125,14.594658984375Q16.751772578125,16.631258984375002,14.739248578125,17.847858984375Q14.525103578125,17.995758984375,14.264892578125,17.995758984375Q14.182652278125,17.995758984375,14.101992578125,17.979658984375Q14.021332578125,17.963658984375,13.945351578125,17.932158984375Q13.869371578125,17.900658984375,13.800991578125,17.854958984375Q13.732611578125,17.809358984375002,13.674458578125,17.751158984375Q13.616305578125,17.692958984375,13.570615578125,17.624658984375Q13.524925578125,17.556258984375,13.493453578125,17.480258984375Q13.461981578125,17.404258984374998,13.445936578125,17.323658984375Q13.429892578125,17.242958984375,13.429892578125,17.160758984375Q13.429892578125,17.045958984374998,13.460862578124999,16.935458984375Q13.491831578125,16.824858984375,13.551473578125,16.726858984375Q13.611115578125,16.628758984375,13.695005578125,16.550458984375Q13.778896578125,16.472058984375,13.880811578125,16.419258984375Q15.525652578125,15.423558984375,16.492012578125,13.758158984375Q17.499932578125,12.021168984375,17.499932578125,10.000018984375Q17.499932578125,8.010658984374999,16.521692578125,6.293508984375Q15.584492578125,4.648418984375,13.982075578125,3.643182984375Q13.882499578125,3.589574984375,13.800770578125,3.511412984375Q13.719041578125,3.433249984375,13.661047578125,3.336162984375Q13.603053578125,3.239076984375,13.572972578125,3.130062984375Q13.542891578125,3.021047984375,13.542891578125,2.907958984375Q13.542891578125,2.825718684375,13.558936578125,2.745058984375Q13.574980578125,2.664398984375,13.606452578125,2.588417984375Q13.637924578125,2.512437984375,13.683614578125,2.444057984375Q13.729305578125,2.3756779843749998,13.787457578125,2.317524984375Q13.845610578125,2.259371984375,13.913990578125,2.213681984375Q13.982370578125,2.167991984375,14.058351578125,2.136519984375Q14.134331578125,2.105047984375,14.214991478125,2.089002984375Q14.295651478125,2.072958984375,14.377891578125,2.072958984375Q14.508378578125,2.072958984375,14.632644578125,2.112769984375Q14.756910578125,2.152579984375,14.863105578125,2.228405984375Z"
fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1" style="mix-blend-mode:passthrough" />
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,33 @@
import Iconfont from './index.vue'
import CameraOff from './icons/CameraOff.vue'
import CameraOn from './icons/CameraOn.vue'
import CheckIcon from './icons/Check.vue'
import MicOff from './icons/MicOff.vue'
import MicOn from './icons/MicOn.vue'
import PictureInPicture from './icons/PictureInPicture.vue'
import Send from './icons/Send.vue'
import SideBySide from './icons/SideBySide.vue'
import Stop from './icons/Stop.vue'
import VolumeOff from './icons/VolumeOff.vue'
import VolumeOn from './icons/VolumeOn.vue'
import SubtitleOff from './icons/SubtitleOff.vue'
import SubtitleOn from './icons/SubtitleOn.vue'
export {
CameraOff,
CameraOn,
CheckIcon,
MicOff,
MicOn,
PictureInPicture,
Send,
SideBySide,
Stop,
VolumeOff,
VolumeOn,
SubtitleOff,
SubtitleOn,
}
export default Iconfont

View File

@@ -0,0 +1,31 @@
<template>
<component
:is="icon"
class="icon"
:style="{ fontSize: fontSize + 'px', color }"
></component>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
color?: string
fontSize?: number
icon: any
}>(),
{}
)
const emit = defineEmits([])
</script>
<style scoped lang="less">
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
color: inherit;
font-size: inherit;
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { StreamState } from '@/interface/voiceChat';
import { computed, onUnmounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
streamState: StreamState;
audioSourceCallback: () => MediaStream | null;
icon: string;
iconButtonColor: string;
pulseColor: string;
iconRadius: number;
}>(),
{
streamState: StreamState.closed,
iconButtonColor: 'var(--color-accent)',
pulseColor: 'var(--color-accent)',
iconRadius: 50,
},
);
let audioContext: AudioContext;
let analyser: AnalyserNode;
let dataArray: Uint8Array;
let animationId: number;
let pulseScale = ref(1);
let pulseIntensity = ref(0);
watch(
() => props.streamState,
() => {
if (props.streamState === 'open') setupAudioContext();
},
);
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (audioContext) {
audioContext.close();
}
});
function setupAudioContext() {
// @ts-ignore
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const mediaStream = props.audioSourceCallback();
if (mediaStream) {
const source = audioContext.createMediaStreamSource(mediaStream);
source.connect(analyser);
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.8;
dataArray = new Uint8Array(analyser.frequencyBinCount);
updateVisualization();
}
}
function updateVisualization() {
analyser.getByteFrequencyData(dataArray);
// Calculate average amplitude for pulse effect
const average = Array.from(dataArray).reduce((a, b) => a + b, 0) / dataArray.length;
const normalizedAverage = average / 255;
pulseScale.value = 1 + normalizedAverage * 0.15;
pulseIntensity.value = normalizedAverage;
animationId = requestAnimationFrame(updateVisualization);
}
const maxPulseScale = computed(() => 1 + pulseIntensity.value * 10); // Scale from 1x to 3x based on intensity
</script>
<template>
<div class="gradio-webrtc-icon-wrapper">
<div class="gradio-webrtc-pulsing-icon-container">
<template v-if="pulseIntensity > 0">
<template v-for="(_, i) in Array(3)" :key="i">
<div
class="pulse-ring"
:style="{
background: pulseColor,
'animation-delay': `${i * 0.4}s`,
'--max-scale': maxPulseScale,
opacity: 0.5 * pulseIntensity,
}"
/>
</template>
</template>
<div
class="gradio-webrtc-pulsing-icon"
:style="{ transform: `scale(${pulseScale})`, background: iconButtonColor }"
>
<template v-if="typeof icon === 'string'">
<img
:src="icon"
alt="Audio visualization icon"
class="icon-image"
:style="{ 'border-radius': `${iconRadius}%` }"
/>
</template>
<template v-else-if="icon === undefined">
<div></div>
</template>
<template v-else>
<div>
<component :is="icon" />
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.gradio-webrtc-icon-wrapper {
position: relative;
display: flex;
max-height: 128px;
justify-content: center;
align-items: center;
}
.gradio-webrtc-pulsing-icon-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.gradio-webrtc-pulsing-icon {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
transition: transform 0.1s ease;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
}
.icon-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.pulse-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
border-radius: 50%;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
opacity: 0.5;
min-width: 18px;
min-height: 18px;
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(var(--max-scale, 3));
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useVideoChatStore } from '@/store';
import { useVisionStore } from '@/store/vision';
import { VideoCameraOutlined } from '@ant-design/icons-vue';
import { onMounted } from 'vue';
const emits = defineEmits(['click']);
const videoChatState = useVideoChatStore();
const visionState = useVisionStore();
const accessClick = async () => {
videoChatState.accessDevice();
};
onMounted(() => {
// accessClick(); //自动获取权限
});
const text = '点击允许访问摄像头和麦克风';
</script>
<template>
<div class="access-wrap" @click="accessClick">
<span class="icon-wrap">
<VideoCameraOutlined />
</span>
{{ text }}
</div>
</template>
<style lang="less" scoped>
.access-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.icon-wrap {
width: 30px;
font-size: 40px;
}
</style>

270
src/helpers/player.ts Normal file
View File

@@ -0,0 +1,270 @@
import type EventEmitter from 'eventemitter3'
import { nanoid } from 'nanoid'
import { PlayerEventTypes } from '../interface/eventType'
interface IOption {
// 传入的数据是采用多少位编码默认16位
channels: number
// 缓存时间 单位 ms
fftSize: number
inputCodec: 'Int8' | 'Int16' | 'Int32' | 'Float32'
// analyserNode fftSize
onended: (extParams?: IExtInfo) => void
// 采样率 单位Hz
sampleRate: number
// 是否静音
isMute: boolean
}
interface ITypedArrays {
Float32: typeof Float32Array
Int16: typeof Int16Array
Int32: typeof Int32Array
Int8: typeof Int8Array
}
type IExtInfo = Record<string, unknown>
interface ISamples {
data: Float32Array
end_of_batch: boolean
startTime?: number
}
export class Player {
static isTypedArray(
data: Int8Array | Int16Array | Int32Array | Float32Array,
) {
// 检测输入的数据是否为 TypedArray 类型或 ArrayBuffer 类型
return (
(data.byteLength &&
data.buffer &&
data.buffer.constructor === ArrayBuffer) ||
data.constructor === ArrayBuffer
)
}
id = nanoid()
analyserNode?: AnalyserNode
audioCtx?: AudioContext
// 是否自动播放
autoPlay = true
bufferSource?: AudioBufferSourceNode
convertValue = 32768
ee: EventEmitter
gainNode?: GainNode
option: IOption = {
inputCodec: 'Int16', // 传入的数据是采用多少位编码默认16位
channels: 1, // 声道数
sampleRate: 8000, // 采样率 单位Hz
fftSize: 2048, // analyserNode fftSize
onended: () => {},
isMute: false,
}
samplesList: ISamples[] = []
startTime?: number
typedArray?:
| typeof Int8Array
| typeof Int16Array
| typeof Int32Array
| typeof Float32Array
_firstStartRelativeTime?: number
_firstStartAbsoluteTime?: number
constructor(option: IOption, ee: EventEmitter) {
this.ee = ee
this.init(option)
}
async continue() {
await this.audioCtx!.resume()
}
destroy() {
this.samplesList = []
this.audioCtx?.close()
this.audioCtx = undefined
}
feed(audioOptions: {
audio: Int8Array | Int16Array | Int32Array | Float32Array
end_of_batch: boolean
}) {
let { audio } = audioOptions
const { end_of_batch } = audioOptions
if (!audio) {
return
}
this._isSupported(audio)
// 获取格式化后的buffer
audio = this._getFormattedValue(audio)
// 开始拷贝buffer数据
// 新建一个Float32Array的空间
const data = new Float32Array(audio.length)
// 复制传入的新数据
// 从历史buff位置开始
data.set(audio, 0)
// 将新的完整buff数据赋值给samples
const samples = {
data,
end_of_batch,
}
this.samplesList.push(samples)
this.flush(samples, this.samplesList.length - 1)
}
flush(samples: ISamples, index: number) {
if (!(samples && this.autoPlay && this.audioCtx)) return
const { data, end_of_batch } = samples
if (this.bufferSource) {
this.bufferSource.onended = () => {}
}
this.bufferSource = this.audioCtx!.createBufferSource()
if (typeof this.option.onended === 'function') {
this.bufferSource.onended = () => {
if (!end_of_batch && index === this.samplesList.length - 1) {
this.ee.emit(PlayerEventTypes.Player_WaitNextAudioClip)
}
this.option.onended()
}
}
const length = data.length / this.option.channels
const audioBuffer = this.audioCtx!.createBuffer(
this.option.channels,
length,
this.option.sampleRate,
)
for (let channel = 0; channel < this.option.channels; channel++) {
const audioData = audioBuffer.getChannelData(channel)
let offset = channel
let decrement = 50
for (let i = 0; i < length; i++) {
audioData[i] = data[offset]
/* fadein */
if (i < 50) {
audioData[i] = (audioData[i] * i) / 50
}
/* fadeout */
if (i >= length - 51) {
audioData[i] = (audioData[i] * decrement--) / 50
}
offset += this.option.channels
}
}
if (this.startTime! < this.audioCtx!.currentTime) {
this.startTime = this.audioCtx!.currentTime
}
this.bufferSource.buffer = audioBuffer
this.bufferSource.connect(this.gainNode!)
this.bufferSource.connect(this.analyserNode!) // bufferSource连接到analyser
this.bufferSource.start(this.startTime)
samples.startTime = this.startTime
if (this._firstStartAbsoluteTime === undefined) {
this._firstStartAbsoluteTime = Date.now()
}
if (this._firstStartRelativeTime === undefined) {
this._firstStartRelativeTime = this.startTime
this.ee.emit(PlayerEventTypes.Player_StartSpeaking, this)
}
this.startTime! += audioBuffer.duration
}
init(option: IOption) {
this.option = Object.assign(this.option, option) // 实例最终配置参数
this.convertValue = this._getConvertValue()
this.typedArray = this._getTypedArray()
this.initAudioContext()
}
initAudioContext() {
// 初始化音频上下文的东西
// @ts-ignore webkitAudioContext
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
// 控制音量的 GainNode
// https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createGain
this.gainNode = this.audioCtx.createGain()
this.gainNode.gain.value = this.option.isMute ? 0 : 1
this.gainNode.connect(this.audioCtx.destination)
this.startTime = this.audioCtx.currentTime
this.analyserNode = this.audioCtx.createAnalyser()
this.analyserNode.fftSize = this.option.fftSize
}
setMute(isMute: boolean) {
this.gainNode!.gain.value = isMute ? 0 : 1
}
async pause() {
await this.audioCtx!.suspend()
}
async updateAutoPlay(value: boolean) {
if (this.autoPlay !== value && value) {
this.autoPlay = value
this.samplesList.forEach((sample, index) => {
this.flush(sample, index)
})
} else {
this.autoPlay = value
}
}
volume(volume: number) {
this.gainNode!.gain.value = volume
}
_getFormattedValue(data: Int8Array | Int16Array | Int32Array | Float32Array) {
const TargetArray = this.typedArray!
if (data.constructor === ArrayBuffer) {
data = new TargetArray(data)
} else {
data = new TargetArray(data.buffer)
}
const float32 = new Float32Array(data.length)
for (let i = 0; i < data.length; i++) {
// buffer 缓冲区的数据需要是IEEE754 里32位的线性PCM范围从-1到+1
// 所以对数据进行除法
// 除以对应的位数范围,得到-1到+1的数据
// float32[i] = data[i] / 0x8000;
float32[i] = data[i] / this.convertValue
}
return float32
}
private _isSupported(
data: Int8Array | Int16Array | Int32Array | Float32Array,
) {
// 数据类型是否支持
// 目前支持 ArrayBuffer 或者 TypedArray
if (!Player.isTypedArray(data))
throw new Error('请传入ArrayBuffer或者任意TypedArray')
return true
}
private _getConvertValue() {
// 根据传入的目标编码位数
// 选定转换数据所需要的基本值
const inputCodecs = {
Int8: 128,
Int16: 32768,
Int32: 2147483648,
Float32: 1,
}
if (!inputCodecs[this.option.inputCodec])
throw new Error(
'wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32',
)
return inputCodecs[this.option.inputCodec]
}
private _getTypedArray() {
// 根据传入的目标编码位数
// 选定前端的所需要的保存的二进制数据格式
// 完整TypedArray请看文档
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
const typedArrays: ITypedArrays = {
Int8: Int8Array,
Int16: Int16Array,
Int32: Int32Array,
Float32: Float32Array,
}
if (!typedArrays[this.option.inputCodec])
throw new Error(
'wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32',
)
return typedArrays[this.option.inputCodec]
}
}

604
src/helpers/processor.ts Normal file
View File

@@ -0,0 +1,604 @@
import EventEmitter from 'eventemitter3'
import PQueue from 'p-queue'
import {
EventTypes,
PlayerEventTypes,
ProcessorEventTypes,
} from '../interface/eventType'
import { unpack } from '../utils/binaryUtils'
import { Player } from './player'
export type IPayload = Record<string, string | number | object | Blob>
interface IDataRecords {
channel_names?: string[]
data_id: number
data_offset: number
data_type: string
sample_rate: number
shape: number[]
}
interface IEvent {
avatar_status?: string
event_type: string
speech_id: string
}
interface IParsedData {
batch_id?: number
batch_name?: string
data_records: Record<string, IDataRecords>
end_of_batch: boolean
events: IEvent[]
}
interface IAvatarMotionData {
// 数据大小,首包存在该值
binary_size: number
// 是否首包
first_package: boolean
// 数据分片,非首包存在该值
motion_data_slice?: Blob
// 分片数量,首包存在该值
segment_num?: number
// 分片索引,非首包存在该值
slice_index?: number
// 是否使用二进制帧,首包存在该值
use_binary_frame?: boolean
// 初始化的音频是否静音
is_audio_mute?: boolean
}
interface IAvatarMotionGroupBase {
arkitFaceArrayBufferArray?: ArrayBuffer[]
batch_id?: number
batch_name?: string
binSize?: number
jsonSize?: number
merged_motion_data: Uint8Array
motion_data_slices: Blob[]
player?: Player
tts2faceArrayBufferArray?: ArrayBuffer[]
}
interface IAvatarMotionGroup extends IAvatarMotionGroupBase {
binary_size: number
first_package: boolean
segment_num?: number
use_binary_frame?: boolean
}
const InputCodecs: Record<string, 'Int8' | 'Int16' | 'Int32' | 'Float32'> = {
int16: 'Int16',
int32: 'Int32',
float32: 'Float32',
}
const TypedArrays: Record<
string,
typeof Int16Array | typeof Int32Array | typeof Float32Array
> = {
int16: Int16Array,
int32: Int32Array,
float32: Float32Array,
}
export class Processor {
private ee: EventEmitter
private _motionDataGroupHandlerQueue = new PQueue({
concurrency: 1,
})
private _motionDataGroups: IAvatarMotionGroup[] = []
private _arkit_face_sample_rate?: number
private _arkit_face_channel_names?: string[]
private _tts2face_sample_rate?: number
private _tts2face_channel_names?: string[]
private _maxBatchId?: number
private _arkitFaceShape?: number
private _tts2FaceShape?: number
constructor(ee: EventEmitter) {
this.ee = ee
}
add(payload: IPayload) {
const { avatar_motion_data } = payload
this._motionDataGroupHandlerQueue.add(
async () =>
await this._motionDataGroupHandler(
avatar_motion_data as IAvatarMotionData,
),
)
}
clear() {
this._motionDataGroups.forEach((group) => {
group.player?.destroy()
})
this._motionDataGroups = []
}
setMute(isMute: boolean) {
this._motionDataGroups.forEach((group) => {
group.player?.setMute(isMute)
})
}
getArkitFaceFrame() {
return {
arkitFace: this._getArkitFaceFrame(),
}
}
getLastBatchId() {
let batch_id
this._motionDataGroups.forEach((group) => {
if (group.batch_id) {
batch_id = group.batch_id
}
})
return batch_id
}
getTtt2FaceFrame() {
return {
tts2Face: this._getTts2FaceFrame(),
}
}
interrupt() {
this._motionDataGroups.forEach((group) => {
if (group.batch_id) {
this._maxBatchId = group.batch_id
}
group.player?.destroy()
})
this._motionDataGroups = []
}
private _getArkitFaceFrame() {
if (!this._motionDataGroups.length) {
return null
}
const targetMotion = this._motionDataGroups.find(
(_motion) => _motion.player,
)
if (!targetMotion) {
return null
}
const { arkitFaceArrayBufferArray, player } = targetMotion!
if (
player &&
player._firstStartAbsoluteTime &&
arkitFaceArrayBufferArray &&
arkitFaceArrayBufferArray.length > 0 &&
this._arkitFaceShape &&
this._arkit_face_sample_rate
) {
const offsetTime = Date.now() - player._firstStartAbsoluteTime
let lastIndex = 0
let firstSampleStartTime: number
player.samplesList.forEach((item, index) => {
if (
firstSampleStartTime === undefined &&
item.startTime !== undefined
) {
firstSampleStartTime = item.startTime
}
if (
item.startTime !== undefined &&
item.startTime - firstSampleStartTime <= offsetTime / 1000
) {
lastIndex = index
}
})
const samples = player.samplesList[lastIndex]
const subOffsetTime = offsetTime - samples.startTime! * 1000
const offset = Math.floor(
(subOffsetTime / 1000) * this._arkit_face_sample_rate,
)
const arkitFaceFloat32ArrayArray = new Float32Array(
arkitFaceArrayBufferArray[lastIndex],
)
const subData = arkitFaceFloat32ArrayArray?.slice(
offset * this._arkitFaceShape,
offset * this._arkitFaceShape + this._arkitFaceShape,
)
if (subData?.length) {
const result = {}
const channelNames = this._arkit_face_channel_names || []
channelNames.forEach((channelName, index) => {
Object.assign(result, {
[channelName]: subData[index],
})
})
return result
}
return null
}
return null
}
private _getTts2FaceFrame() {
if (!this._motionDataGroups.length) {
return null
}
const targetMotion = this._motionDataGroups.find(
(_motion) => _motion.player,
)
if (!targetMotion) {
return null
}
const { tts2faceArrayBufferArray, player } = targetMotion!
if (
player &&
player._firstStartAbsoluteTime &&
tts2faceArrayBufferArray &&
tts2faceArrayBufferArray.length > 0 &&
this._tts2FaceShape &&
this._tts2face_sample_rate
) {
const offsetTime = Date.now() - player._firstStartAbsoluteTime
let lastIndex = 0
let firstSampleStartTime: number
player.samplesList.forEach((item, index) => {
if (
firstSampleStartTime === undefined &&
item.startTime !== undefined
) {
firstSampleStartTime = item.startTime
}
if (
item.startTime !== undefined &&
item.startTime - firstSampleStartTime <= offsetTime / 1000
) {
lastIndex = index
}
})
const samples = player.samplesList[lastIndex]
const subOffsetTime = offsetTime - samples.startTime! * 1000
const offset = Math.floor(
(subOffsetTime / 1000) * this._tts2face_sample_rate,
)
const arkitFaceFloat32ArrayArray = new Float32Array(
tts2faceArrayBufferArray[lastIndex],
)
const subData = arkitFaceFloat32ArrayArray?.slice(
offset * this._tts2FaceShape,
offset * this._tts2FaceShape + this._tts2FaceShape,
)
if (subData?.length) {
return subData
}
return null
}
return null
}
private async _motionDataGroupHandler(avatar_motion_data: IAvatarMotionData) {
try {
const {
first_package,
motion_data_slice,
segment_num,
binary_size,
use_binary_frame,
is_audio_mute,
} = avatar_motion_data
if (first_package) {
const lastMotionGroup =
this._motionDataGroups[this._motionDataGroups.length - 1]
if (lastMotionGroup) {
// 检测上一大片数量是否丢包
if (
lastMotionGroup.segment_num !==
lastMotionGroup.motion_data_slices.length
) {
// 丢包触发错误
this.ee.emit(EventTypes.ErrorReceived, 'lost data packets')
}
}
this._motionDataGroups.push({
first_package,
binary_size,
segment_num,
use_binary_frame,
motion_data_slices: [],
merged_motion_data: new Uint8Array(binary_size),
})
} else {
if (this._motionDataGroups.length === 0) {
return
}
if (!motion_data_slice) {
return
}
const lastMotionGroup =
this._motionDataGroups[this._motionDataGroups.length - 1]
const prevMotionGroup =
this._motionDataGroups[this._motionDataGroups.length - 2]
lastMotionGroup.motion_data_slices.push(motion_data_slice)
if (
lastMotionGroup.motion_data_slices.length ===
lastMotionGroup.segment_num
) {
// 单段不分小片段的情况不需要mergeBlob为了兼容后续逻辑这里直接赋值
const blob = lastMotionGroup.motion_data_slices[0]
// const blob = mergeBlob(
// lastMotionGroup.motion_data_slices,
// lastMotionGroup.merged_motion_data,
// );
const { parsedData, jsonSize, binSize } = await unpack(blob)
lastMotionGroup.jsonSize = jsonSize
lastMotionGroup.binSize = binSize
const bin = blob.slice(12 + lastMotionGroup.jsonSize!)
if (bin.size !== lastMotionGroup.binSize) {
this.ee.emit(ProcessorEventTypes.Chat_BinsizeError)
}
const batchCheckResult = this._connectBatch(
parsedData,
lastMotionGroup,
prevMotionGroup,
)
if (!batchCheckResult) {
return
}
await this._handleArkitFaceConfig(
parsedData,
lastMotionGroup,
prevMotionGroup,
bin,
)
// await this._handletts2faceConfig(
// parsedData,
// lastMotionGroup,
// prevMotionGroup,
// bin,
// );
await this._handleAudioConfig(
parsedData,
lastMotionGroup,
prevMotionGroup,
bin,
is_audio_mute || false,
)
this._handleEvents(parsedData)
}
}
} catch (err: unknown) {
console.error('err', err)
this.ee.emit(EventTypes.ErrorReceived, (err as Error).message)
}
}
private async _handleAudioConfig(
parsedData: IParsedData,
lastMotionGroup: IAvatarMotionGroup,
prevMotionGroup: IAvatarMotionGroup,
bin: Blob,
isPlayerMute: boolean,
) {
const { data_records = {}, end_of_batch } = parsedData
const { audio } = data_records
if (audio) {
const { sample_rate, shape, data_offset, data_type } = audio
const inputCodec = InputCodecs[data_type]
const TargetTypedArray = TypedArrays[data_type]
if (lastMotionGroup.player === undefined) {
if (
prevMotionGroup &&
prevMotionGroup.player &&
prevMotionGroup.batch_id === lastMotionGroup.batch_id
) {
lastMotionGroup.player = prevMotionGroup.player
} else if (sample_rate) {
lastMotionGroup.player = new Player(
{
inputCodec,
channels: 1,
sampleRate: sample_rate,
fftSize: 1024,
isMute: isPlayerMute,
onended: (option) => {
if (!option) {
return
}
const {
end_of_batch: innerEndOfBatch,
lastMotionGroup: innerLastMotion,
} = option
if (innerEndOfBatch) {
const { batch_id, player } =
innerLastMotion as IAvatarMotionGroup
this.ee.emit(PlayerEventTypes.Player_EndSpeaking, player)
this._motionDataGroups = this._motionDataGroups.filter(
(item) => item.batch_id! > batch_id!,
)
if (
this._motionDataGroups.length &&
this._motionDataGroups[0].player
) {
this._motionDataGroups[0].player.updateAutoPlay(true)
} else {
this.ee.emit(PlayerEventTypes.Player_NoLegacy)
}
}
},
},
this.ee,
)
}
if (end_of_batch) {
const originEnded = lastMotionGroup.player!.option.onended
lastMotionGroup.player!.option.onended = () => {
originEnded({
end_of_batch,
lastMotionGroup,
})
}
}
}
const shapeLength = shape.reduce(
(acc: number, cur: number) => acc * cur,
inputCodec === 'Int16' ? 2 : 4,
)
const audioBlobSliceStart = data_offset
const audioBlobSliceEnd = data_offset + shapeLength
const audioBlob = bin.slice(audioBlobSliceStart, audioBlobSliceEnd)
const audioArrayBuffer = await audioBlob.arrayBuffer()
// 如果前一段还没播放结束,后一段已接收到,那么后一段则不能自动播放
const prevHasPlayerMotionDataGroup = this._motionDataGroups.find(
(item) => item.player,
)
if (
this._motionDataGroups.length &&
lastMotionGroup.player &&
prevHasPlayerMotionDataGroup &&
prevHasPlayerMotionDataGroup.player !== lastMotionGroup.player
) {
lastMotionGroup.player.autoPlay = false
}
if (lastMotionGroup.player) {
lastMotionGroup.player.feed({
audio: new TargetTypedArray(audioArrayBuffer),
end_of_batch,
})
}
} else if (
// 特殊事件motion挂上这个
prevMotionGroup &&
prevMotionGroup.player &&
lastMotionGroup.batch_id === prevMotionGroup.batch_id
) {
lastMotionGroup.player = prevMotionGroup.player
}
}
private async _handleArkitFaceConfig(
parsedData: IParsedData,
lastMotionGroup: IAvatarMotionGroup,
prevMotionGroup: IAvatarMotionGroup,
bin: Blob,
) {
const { data_records = {} } = parsedData
const { arkit_face } = data_records
if (arkit_face) {
const { channel_names, shape, data_offset, sample_rate } =
arkit_face as IDataRecords
if (channel_names && !this._arkit_face_channel_names) {
this._arkit_face_channel_names = channel_names
this._arkit_face_sample_rate = sample_rate
}
if (lastMotionGroup.arkitFaceArrayBufferArray === undefined) {
if (
prevMotionGroup &&
prevMotionGroup.arkitFaceArrayBufferArray &&
prevMotionGroup.batch_id === lastMotionGroup.batch_id
) {
lastMotionGroup.arkitFaceArrayBufferArray =
prevMotionGroup.arkitFaceArrayBufferArray
} else {
lastMotionGroup.arkitFaceArrayBufferArray = []
}
const shapeLength = shape.reduce(
(acc: number, cur: number) => acc * cur,
4,
)
this._arkitFaceShape = shape[1]
const arkitFaceBlob = bin.slice(data_offset, data_offset + shapeLength)
const arkitFaceArrayBuffer = await arkitFaceBlob.arrayBuffer()
lastMotionGroup.arkitFaceArrayBufferArray.push(arkitFaceArrayBuffer)
}
} else if (
prevMotionGroup &&
prevMotionGroup.arkitFaceArrayBufferArray &&
lastMotionGroup.batch_id === prevMotionGroup.batch_id
) {
lastMotionGroup.arkitFaceArrayBufferArray =
prevMotionGroup.arkitFaceArrayBufferArray
}
}
private async _handletts2faceConfig(
parsedData: IParsedData,
lastMotionGroup: IAvatarMotionGroup,
prevMotionGroup: IAvatarMotionGroup,
bin: Blob,
) {
const { data_records = {} } = parsedData
const { tts2face } = data_records
if (tts2face) {
const { channel_names, shape, data_offset, sample_rate } =
tts2face as IDataRecords
if (channel_names && !this._tts2face_channel_names) {
this._tts2face_channel_names = channel_names
this._tts2face_sample_rate = sample_rate
}
if (lastMotionGroup.tts2faceArrayBufferArray === undefined) {
if (
prevMotionGroup &&
prevMotionGroup.tts2faceArrayBufferArray &&
prevMotionGroup.batch_id === lastMotionGroup.batch_id
) {
lastMotionGroup.tts2faceArrayBufferArray =
prevMotionGroup.tts2faceArrayBufferArray
} else {
lastMotionGroup.tts2faceArrayBufferArray = []
}
const shapeLength = shape.reduce(
(acc: number, cur: number) => acc * cur,
4,
)
this._tts2FaceShape = shape[1]
const tts2faceBlob = bin.slice(data_offset, data_offset + shapeLength)
const tts2faceArrayBuffer = await tts2faceBlob.arrayBuffer()
lastMotionGroup.tts2faceArrayBufferArray.push(tts2faceArrayBuffer)
}
} else if (
prevMotionGroup &&
prevMotionGroup.tts2faceArrayBufferArray &&
lastMotionGroup.batch_id === prevMotionGroup.batch_id
) {
lastMotionGroup.tts2faceArrayBufferArray =
prevMotionGroup.tts2faceArrayBufferArray
}
}
private _handleEvents(parsedData: IParsedData) {
const { events } = parsedData
if (events && events.length) {
events.forEach((e) => {
switch (e.event_type) {
case 'interrupt_speech':
// console.log('HandleEvents: interrupt_speech')
break
case 'change_status':
// console.log('HandleEvents: change_status')
this.ee.emit(ProcessorEventTypes.Change_Status, e)
break
default:
break
}
})
}
}
private _connectBatch(
parsedData: IParsedData,
lastMotionGroup: IAvatarMotionGroup,
prevMotionGroup: IAvatarMotionGroup,
) {
let batchCheckResult = true
// 处理二进制batch_id
if (parsedData.batch_id && lastMotionGroup.batch_id === undefined) {
lastMotionGroup.batch_id = parsedData.batch_id
}
// 特殊事件motion如果没有batch_id也可挂上此batch_id
if (
!lastMotionGroup.batch_id &&
prevMotionGroup &&
prevMotionGroup.batch_id
) {
lastMotionGroup.batch_id = prevMotionGroup.batch_id
}
// 特殊事件motion如果没有batch_name也可挂上此batch_name
if (parsedData.batch_name && lastMotionGroup.batch_name === undefined) {
lastMotionGroup.batch_name = parsedData.batch_name
}
// 处理打断后如果仍接收到上一个batch的motionData, 那么重新销毁
if (
this._maxBatchId &&
lastMotionGroup.batch_id &&
lastMotionGroup.batch_id <= this._maxBatchId
) {
this.clear()
batchCheckResult = false
}
return batchCheckResult
}
}

40
src/helpers/ws.ts Normal file
View File

@@ -0,0 +1,40 @@
import EventEmitter from 'eventemitter3'
import { WsEventTypes } from '../interface/eventType'
export class WS extends EventEmitter {
engine: WebSocket | undefined
private _inited = false
constructor(url: string) {
super()
this._init(url)
}
private _init(url: string) {
if (this._inited) {
return
}
this._inited = true
this.engine = new WebSocket(url)
this.engine.addEventListener('error', (event) => {
this.emit(WsEventTypes.WS_ERROR, event)
})
this.engine.addEventListener('open', () => {
this.emit(WsEventTypes.WS_OPEN)
})
this.engine.addEventListener('message', (event) => {
this.emit(WsEventTypes.WS_MESSAGE, event.data)
})
this.engine.addEventListener('close', () => {
this.emit(WsEventTypes.WS_CLOSE)
})
}
send(data: string | Int8Array | Uint8Array) {
this.engine?.send(data)
}
stop() {
this.emit(WsEventTypes.WS_CLOSE)
this._inited = false
this.engine?.close()
}
}

1
src/index.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'click-outside-vue3'

View File

@@ -0,0 +1,28 @@
export enum EventTypes {
'ErrorReceived' = 'ErrorReceived',
'MessageReceived' = 'MessageReceived',
'StartSpeech' = 'StartSpeech',
'EndSpeech' = 'EndSpeech',
'StateChanged' = 'StateChanged',
}
export enum WsEventTypes {
'WS_CLOSE' = 'WS_CLOSE',
'WS_ERROR' = 'WS_ERROR',
'WS_MESSAGE' = 'WS_MESSAGE',
'WS_OPEN' = 'WS_OPEN',
}
export enum PlayerEventTypes {
// Player没断
'Player_EndSpeaking' = 'Player_EndSpeaking',
'Player_NoLegacy' = 'Player_NoLegacy',
// Player相关
'Player_StartSpeaking' = 'Player_StartSpeaking',
'Player_WaitNextAudioClip' = 'Player_WaitNextAudioClip',
}
// 端测渲染(端到端)、单独输出数字人处理核心数据Processor相关的事件
export enum ProcessorEventTypes {
'Change_Status' = 'Change_Status',
'Chat_BinsizeError' = 'Chat_BinsizeError',
}

View File

@@ -0,0 +1,12 @@
export enum TYVoiceChatState {
Idle = 'Idle',
Listening = 'Listening',
Responding = 'Responding',
Thinking = 'Thinking',
}
export enum StreamState {
closed = 'closed',
open = 'open',
waiting = 'waiting',
}

5
src/langs/en.ts Normal file
View File

@@ -0,0 +1,5 @@
export default {
message: {
hello: 'hello world',
},
}

32
src/langs/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createI18n } from 'vue-i18n'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import enUS from 'ant-design-vue/es/locale/en_US'
import { ref } from 'vue'
import en from './en'
import zh from './zh'
type SupportLocale = keyof typeof messages
export const locale = ref<SupportLocale>('zh')
export const antdLocale: Record<SupportLocale, any> = {
zh: zhCN,
en: enUS,
}
const messages = {
en,
zh,
}
const i18n = createI18n({
legacy: false,
locale: locale.value,
messages,
})
export const changeLanguage = (lang: SupportLocale) => {
locale.value = lang
i18n.global.locale.value = lang
}
export default i18n

5
src/langs/zh.ts Normal file
View File

@@ -0,0 +1,5 @@
export default {
message: {
hello: '你好,世界',
},
}

13
src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.less'
import App from './App.vue'
import i18n from './langs'
import vClickOutside from 'click-outside-vue3'
const app = createApp(App)
const Pinia = createPinia()
app.use(Pinia)
app.use(i18n)
app.use(vClickOutside)
app.mount('#app')

410
src/store/index.ts Normal file
View File

@@ -0,0 +1,410 @@
import { WS } from '@/helpers/ws'
import { WsEventTypes } from '@/interface/eventType'
import { StreamState } from '@/interface/voiceChat'
import { GaussianAvatar } from '@/utils/gaussianAvatar'
import {
createSimulatedAudioTrack,
createSimulatedVideoTrack,
getDevices,
getStream,
setAvailableDevices,
} from '@/utils/streamUtils'
import { setupWebRTC, stop } from '@/utils/webrtcUtils'
import { message } from 'ant-design-vue'
import { defineStore } from 'pinia'
import { useVisionStore } from './vision'
const track_constraints = {
video: {
width: 500,
height: 500,
},
audio: true,
}
interface VideoChatState {
devices: MediaDeviceInfo[]
availableVideoDevices: MediaDeviceInfo[]
availableAudioDevices: MediaDeviceInfo[]
selectedVideoDevice: MediaDeviceInfo | null
selectedAudioDevice: MediaDeviceInfo | null
streamState: StreamState
stream: MediaStream | null
peerConnection: RTCPeerConnection | null
localStream: MediaStream | null
webcamAccessed: boolean
webRTCId: string
avatarType: '' | 'lam'
avatarWSRoute: string
avatarAssetsPath: string
rtcConfig: RTCConfiguration | undefined
trackConstraints:
| {
video: MediaTrackConstraints | boolean
audio: MediaTrackConstraints | boolean
}
| undefined
gsLoadPercent: number
volumeMuted: boolean
micMuted: boolean
cameraOff: boolean
hasCamera: boolean
hasCameraPermission: boolean
hasMic: boolean
hasMicPermission: boolean
showChatRecords: boolean
localAvatarRenderer: any
chatDataChannel: RTCDataChannel | null
replying: boolean
chatRecords: Array<{ id: string; role: 'human' | 'avatar'; message: string }>
}
export const useVideoChatStore = defineStore('videoChatStore', {
state: (): VideoChatState => {
return {
devices: [],
availableVideoDevices: [],
availableAudioDevices: [],
selectedVideoDevice: null,
selectedAudioDevice: null,
streamState: StreamState.closed,
stream: null,
peerConnection: null,
localStream: null,
webRTCId: '',
webcamAccessed: false,
avatarType: '',
avatarWSRoute: '',
avatarAssetsPath: '',
rtcConfig: undefined,
trackConstraints: track_constraints,
gsLoadPercent: 0,
volumeMuted: false,
micMuted: false,
cameraOff: false,
hasCamera: false,
hasCameraPermission: true,
hasMic: false,
hasMicPermission: true,
showChatRecords: false,
localAvatarRenderer: null,
chatDataChannel: null,
replying: false,
chatRecords: [],
}
},
getters: {},
actions: {
async accessDevice() {
try {
const visionState = useVisionStore()
const node = visionState.localVideoRef
this.micMuted = false
this.cameraOff = false
this.volumeMuted = false
if (!navigator.mediaDevices) {
message.error(
'无法获取媒体设备请确保用localhost访问或https协议访问',
)
return
}
await navigator.mediaDevices
.getUserMedia({
audio: true,
})
.catch(() => {
console.log('no audio permission')
this.hasMicPermission = false
})
await navigator.mediaDevices
.getUserMedia({
video: true,
})
.catch(() => {
console.log('no video permission')
this.hasCameraPermission = false
})
const devices = await getDevices()
this.devices = devices
console.log('🚀 ~ access_webcam ~ devices:', devices)
const videoDeviceId =
this.selectedVideoDevice &&
devices.some(
(device) => device.deviceId === this.selectedVideoDevice?.deviceId,
)
? this.selectedVideoDevice.deviceId
: ''
const audioDeviceId =
this.selectedAudioDevice &&
devices.some(
(device) => device.deviceId === this.selectedAudioDevice?.deviceId,
)
? this.selectedAudioDevice.deviceId
: ''
console.log(videoDeviceId, audioDeviceId, ' access web device')
this.fillStream(audioDeviceId, videoDeviceId)
this.webcamAccessed = true
} catch (err: any) {
console.log(err)
message.error(err.message)
}
},
async init() {
fetch('/openavatarchat/init')
.then((res) => res.json())
.then((config) => {
if (config.rtc_configuration) {
this.rtcConfig = config.rtc_configuration
}
console.log(config)
if (config.avatar_config) {
this.avatarType = config.avatar_config.avatar_type
this.avatarWSRoute = config.avatar_config.avatar_ws_route
this.avatarAssetsPath = config.avatar_config.avatar_assets_path
}
if (config.track_constraints) {
this.trackConstraints = config.track_constraints
}
})
.catch(() => {
message.error(
'服务端链接失败,请检查是否能正确访问到 OpenAvatarChat 服务端',
)
})
},
handleCameraOff() {
this.cameraOff = !this.cameraOff
this.stream?.getTracks().forEach((track) => {
if (track.kind.includes('video')) track.enabled = !this.cameraOff
})
},
handleMicMuted() {
this.micMuted = !this.micMuted
this.stream?.getTracks().forEach((track) => {
if (track.kind.includes('audio')) track.enabled = !this.micMuted
})
},
handleVolumeMute() {
this.volumeMuted = !this.volumeMuted
if (this.avatarType === 'lam') {
this.localAvatarRenderer?.setAvatarMute(this.volumeMuted)
}
},
async handleDeviceChange(deviceId: string) {
const device_id = deviceId
const devices = await getDevices()
this.devices = devices
console.log('🚀 ~ handle_device_change ~ devices:', devices)
let videoDeviceId =
this.selectedVideoDevice &&
devices.some(
(device) => device.deviceId === this.selectedVideoDevice?.deviceId,
)
? this.selectedVideoDevice.deviceId
: ''
let audioDeviceId =
this.selectedAudioDevice &&
devices.some(
(device) => device.deviceId === this.selectedAudioDevice?.deviceId,
)
? this.selectedAudioDevice.deviceId
: ''
if (
this.availableVideoDevices.find(
(video_device) => video_device.deviceId === device_id,
)
) {
videoDeviceId = device_id
this.cameraOff = false
} else if (
this.availableAudioDevices.find(
(audio_device) => audio_device.deviceId === device_id,
)
) {
audioDeviceId = device_id
this.micMuted = false
}
this.fillStream(audioDeviceId, videoDeviceId)
},
handleSubtitleToggle() {
this.showChatRecords = !this.showChatRecords
},
async updateAvailableDevices() {
const devices = await getDevices()
this.availableVideoDevices = setAvailableDevices(devices, 'videoinput')
this.availableAudioDevices = setAvailableDevices(devices, 'audioinput')
},
async fillStream(audioDeviceId: string, videoDeviceId: string) {
const { devices } = this
const visionState = useVisionStore()
const node = visionState.localVideoRef
this.hasMic =
devices.some((device) => {
return device.kind === 'audioinput' && device.deviceId
}) && this.hasMicPermission
this.hasCamera =
devices.some(
(device) => device.kind === 'videoinput' && device.deviceId,
) && this.hasCameraPermission
await getStream(
audioDeviceId && audioDeviceId !== 'default'
? { deviceId: { exact: audioDeviceId } }
: this.hasMic,
videoDeviceId && videoDeviceId !== 'default'
? { deviceId: { exact: videoDeviceId } }
: this.hasCamera,
this.trackConstraints,
)
.then(async (local_stream) => {
console.log('local_stream', local_stream)
this.stream = local_stream
this.updateAvailableDevices()
})
.then(() => {
const used_devices = this.stream!.getTracks().map(
(track) => track.getSettings()?.deviceId,
)
used_devices.forEach((device_id) => {
const used_device = devices.find(
(device) => device.deviceId === device_id,
)
if (used_device && used_device?.kind.includes('video')) {
this.selectedVideoDevice = used_device
} else if (used_device && used_device?.kind.includes('audio')) {
this.selectedAudioDevice = used_device
}
})
!this.selectedVideoDevice &&
(this.selectedVideoDevice = this.availableVideoDevices[0])
})
.catch((e) => {
console.error('image.no_webcam_support', e)
})
.finally(() => {
console.log(this.stream)
if (!this.stream) {
this.stream = new MediaStream()
}
console.log(this.stream.getTracks())
if (!this.stream.getTracks().find((item) => item.kind === 'audio')) {
this.stream.addTrack(createSimulatedAudioTrack())
}
if (!this.stream.getTracks().find((item) => item.kind === 'video')) {
this.stream.addTrack(createSimulatedVideoTrack())
}
console.log(this.hasCamera, this.hasMic)
this.webcamAccessed = true
this.localStream = this.stream
if (node) {
node.srcObject = this.localStream
node.muted = true
node?.play()
}
})
},
async startWebRTC() {
const visionState = useVisionStore()
if (this.streamState === 'closed') {
this.chatRecords = []
this.peerConnection = new RTCPeerConnection() // TODO RTC_configuration
this.peerConnection.addEventListener(
'connectionstatechange',
async (event) => {
switch (this.peerConnection!.connectionState) {
case 'connected':
this.streamState = StreamState.open
break
case 'disconnected':
this.streamState = StreamState.closed
stop(this.peerConnection!)
// await access_webcam() //TODO 重置状态
break
default:
break
}
},
)
this.streamState = StreamState.waiting
await setupWebRTC(
this.stream!,
this.peerConnection!,
visionState.remoteVideoRef!,
)
.then(([dataChannel, webRTCId]) => {
this.streamState = StreamState.open
this.webRTCId = webRTCId as string
// TODO GS
this.chatDataChannel = dataChannel as any
if (this.avatarType && this.avatarWSRoute) {
const ws = this.initWebsocket(this.avatarWSRoute, this.webRTCId)
if (this.avatarType === 'lam') {
this.localAvatarRenderer = this.doGaussianRender(ws)
}
}
})
.catch((e) => {
console.info('catching', e)
this.streamState = StreamState.closed
message.error(e)
})
} else if (this.streamState === 'waiting') {
// waiting 中不允许操作
} else {
stop(this.peerConnection!)
this.streamState = StreamState.closed
this.chatRecords = []
this.chatDataChannel = null
this.replying = false
await this.accessDevice()
if (this.avatarType === 'lam') {
this.localAvatarRenderer?.exit()
this.gsLoadPercent = 0
}
}
},
initWebsocket(ws_route: string, webRTCId: string) {
const ws = new WS(
`${window.location.protocol.includes('https') ? 'wss' : 'ws'}://${window.location.host}${ws_route}/${webRTCId}`,
)
ws.on(WsEventTypes.WS_OPEN, () => {
console.log('socket opened')
})
ws.on(WsEventTypes.WS_CLOSE, () => {
console.log('socket closed')
})
ws.on(WsEventTypes.WS_ERROR, (event) => {
console.log('socket error', event)
})
ws.on(WsEventTypes.WS_MESSAGE, (data) => {
console.log('socket on message', data)
})
return ws
},
doGaussianRender(ws: WS) {
const visionState = useVisionStore()
const gaussianAvatar = new GaussianAvatar({
container: visionState.remoteVideoContainerRef!,
assetsPath: this.avatarAssetsPath,
ws,
loadProgress: (progress) => {
console.log('gs loadProgress', progress)
this.gsLoadPercent = progress
if (progress >= 1) {
// visionState.computeRemotePosition();
}
},
})
gaussianAvatar.start()
return gaussianAvatar
},
},
})

35
src/store/vision.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
interface VisionState {
wrapperRef: HTMLDivElement | undefined
wrapperRect: { width: number; height: number }
localVideoRef: HTMLVideoElement | undefined
localVideoContainerRef: HTMLDivElement | undefined
remoteVideoRef: HTMLVideoElement | undefined
remoteVideoContainerRef: HTMLDivElement | undefined
isLandscape: boolean
showChatRecords: boolean
}
export const useVisionStore = defineStore('visionStore', {
state: (): VisionState => {
return {
wrapperRect: {
width: 0,
height: 0,
},
wrapperRef: undefined,
localVideoRef: undefined,
localVideoContainerRef: undefined,
remoteVideoRef: undefined,
remoteVideoContainerRef: undefined,
isLandscape: true,
showChatRecords: false,
}
},
actions: {},
})

19
src/style.less Normal file
View File

@@ -0,0 +1,19 @@
body {
margin: 0;
overflow-y: auto;
}
* {
box-sizing: border-box;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent !important;
}
img {
pointer-events: none;
}
#app {
width: 100vw;
height: 100vh;
}

28
src/utils/binaryUtils.ts Normal file
View File

@@ -0,0 +1,28 @@
import base64js from 'base64-js'
import { Buffer } from 'buffer'
import PythonStruct from 'python-struct'
export const unpack = async function (blob: Blob, str = '<II') {
const unpackBuffer = await blob.slice(4, 12).arrayBuffer()
const [jsonSize, binSize] = PythonStruct.unpack(
str,
Buffer.from(unpackBuffer),
) as number[]
const jsonBlob = await blob.slice(12, 12 + jsonSize).text()
const parsedData = JSON.parse(jsonBlob)
return {
parsedData,
jsonSize,
binSize,
}
}
export const mergeBlob = (strArray: string[], target: Uint8Array) => {
let offset = 0
strArray.forEach((str) => {
const byteArray = base64js.toByteArray(str)
target.set(byteArray, offset)
offset += byteArray.byteLength
})
const blob = new Blob([target])
return blob
}

180
src/utils/gaussianAvatar.ts Normal file
View File

@@ -0,0 +1,180 @@
import { Player } from '@/helpers/player'
import { Processor } from '@/helpers/processor'
import { type WS } from '@/helpers/ws.js'
import { EventTypes, PlayerEventTypes } from '@/interface/eventType'
import { TYVoiceChatState } from '@/interface/voiceChat'
import EventEmitter from 'eventemitter3'
// import * as GaussianSplats3D from "./gaussian-splats-3d.module.js";
import { WsEventTypes } from '@/interface/eventType'
// @ts-ignore for lam render
import * as GaussianSplats3D from 'gaussian-splat-renderer-for-lam'
interface GaussianOptions {
container: HTMLDivElement
assetsPath: string
ws: WS
downloadProgress?: (percent: number) => void
loadProgress?: (percent: number) => void
}
export class GaussianAvatar extends EventEmitter {
private _avatarDivEle: HTMLDivElement
private _assetsPath = ''
private _ws: WS
private _downloadProgress: (percent: number) => void
private _loadProgress: (percent: number) => void
private _loadPercent = 0
private _downloadPercent = 0
private _processor!: Processor
private _renderer: any
private _audioMute = false
curState = TYVoiceChatState.Idle
constructor(options: GaussianOptions) {
const { container, assetsPath, ws, downloadProgress, loadProgress } =
options
super()
this._avatarDivEle = container
this._assetsPath = assetsPath
this._ws = ws
if (downloadProgress) {
this._downloadProgress = (percent: number) => {
this._downloadPercent = percent
downloadProgress(percent)
}
} else {
this._downloadProgress = (percent: number) => {
this._downloadPercent = percent
}
}
if (loadProgress) {
this._loadProgress = (percent: number) => {
this._loadPercent = percent
loadProgress(percent)
}
} else {
this._loadProgress = (percent: number) => {
this._loadPercent = percent
}
}
this._init()
}
private _init() {
if (!this._avatarDivEle || !this._assetsPath || !this._ws) {
throw new Error(
'Lack of necessary initialization parameters for gaussian render',
)
}
this._processor = new Processor(this)
this._bindEventTypes()
}
start() {
this.getData()
this.render()
}
async getData() {
this._ws.on(WsEventTypes.WS_MESSAGE, (data: Blob) => {
if (this._downloadPercent < 1 || this._loadPercent < 1) {
// 本地数字人未加载完成前,不处理数据
return
}
this.emit(EventTypes.MessageReceived, this.curState)
this._processor.add({
avatar_motion_data: {
first_package: true, // 是否首包
segment_num: 1, // 分片数量,首包存在该值
binary_size: data.size, // 数据大小,首包存在该值
use_binary_frame: false, // 是否使用二进制帧,首包存在该值
},
})
this._processor.add({
avatar_motion_data: {
first_package: false,
motion_data_slice: data, // 数据分片,非首包存在该值
is_audio_mute: this._audioMute, // 音频片段是否静音,非首包存在该值
},
})
})
}
async render() {
this._renderer = await GaussianSplats3D.GaussianSplatRenderer.getInstance(
this._avatarDivEle,
this._assetsPath,
{
getChatState: this.getChatState.bind(this),
getExpressionData: this.getArkitFaceFrame.bind(this),
downloadProgress: this._downloadProgress.bind(this),
loadProgress: this._loadProgress.bind(this),
},
)
}
setAvatarMute(isMute: boolean) {
this._processor.setMute(isMute)
this._audioMute = isMute
}
getChatState() {
return this.curState
}
getArkitFaceFrame() {
return this._processor?.getArkitFaceFrame().arkitFace
}
interrupt(): void {
this._ws.send('%interrupt%') // 约定的打断标识
this._processor?.interrupt()
this.curState = TYVoiceChatState.Idle
this.emit(EventTypes.StateChanged, this.curState)
}
sendSpeech(data: string | Int8Array | Uint8Array) {
this._ws.send(data)
this.curState = TYVoiceChatState.Listening
this.emit(EventTypes.StateChanged, this.curState)
this._processor?.clear()
}
exit() {
this._renderer?.dispose()
this.curState = TYVoiceChatState.Idle
this._downloadPercent = 0
this._loadPercent = 0
this._processor?.clear()
this.removeAllListeners()
}
private _bindEventTypes() {
this.on(PlayerEventTypes.Player_StartSpeaking, (player: Player) => {
console.log('startSpeach')
this.curState = TYVoiceChatState.Responding
this.emit(EventTypes.StateChanged, this.curState)
this._ws.send(
JSON.stringify({
header: { name: EventTypes.StartSpeech },
payload: {},
}),
)
})
this.on(PlayerEventTypes.Player_EndSpeaking, (player: Player) => {
console.log('endSpeach')
this.curState = TYVoiceChatState.Idle
this.emit(EventTypes.StateChanged, this.curState)
this._ws.send(
JSON.stringify({ header: { name: EventTypes.EndSpeech }, payload: {} }),
)
})
this.on(EventTypes.ErrorReceived, (data) => {
console.log('ErrorReceived', data)
this.curState = TYVoiceChatState.Idle
this.emit(EventTypes.StateChanged, this.curState)
this._ws.send(
JSON.stringify({
header: { name: EventTypes.ErrorReceived },
payload: { ...data },
}),
)
})
this._ws.on(WsEventTypes.WS_CLOSE, () => {
this.exit()
})
}
}

134
src/utils/streamUtils.ts Normal file
View File

@@ -0,0 +1,134 @@
export function getDevices(): Promise<MediaDeviceInfo[]> {
return navigator.mediaDevices.enumerateDevices()
}
export function handleError(error: string): void {
throw new Error(error)
}
export function setLocalStream(
local_stream: MediaStream | null,
video_source: HTMLVideoElement,
): void {
video_source.srcObject = local_stream
video_source.muted = true
video_source.play()
}
export async function getStream(
audio: boolean | { deviceId: { exact: string } },
video: boolean | { deviceId: { exact: string } },
track_constraints?: {
video: MediaTrackConstraints | boolean
audio: MediaTrackConstraints | boolean
},
): Promise<MediaStream> {
const video_fallback_constraints = (track_constraints as any)?.video ||
track_constraints || {
width: { ideal: 500 },
height: { ideal: 500 },
}
const audio_fallback_constraints = (track_constraints as any)?.audio ||
track_constraints || {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
const constraints = {
video:
typeof video === 'object'
? { ...video, ...video_fallback_constraints }
: video,
audio:
typeof audio === 'object'
? { ...audio, ...audio_fallback_constraints }
: audio,
}
console.log(constraints, 'constraints')
return navigator.mediaDevices
.getUserMedia(constraints)
.then((local_stream: MediaStream) => {
console.log(local_stream)
return local_stream
})
}
export function setAvailableDevices(
devices: MediaDeviceInfo[],
kind: 'videoinput' | 'audioinput' = 'videoinput',
): MediaDeviceInfo[] {
const cameras = devices.filter(
(device: MediaDeviceInfo) => device.kind === kind,
)
return cameras
}
let video_track: MediaStreamTrack | null = null
let audio_track: MediaStreamTrack | null = null
export function createSimulatedVideoTrack(width = 1, height = 1) {
// if (video_track) return video_track
// 创建一个 canvas 元素
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
canvas.width = width || 500
canvas.height = height || 500
canvas.style.width = '1px'
canvas.style.height = '1px'
canvas.style.position = 'fixed'
canvas.style.visibility = 'hidden'
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.fillStyle = `hsl(0,0, 0, 1)` // 动态颜色
ctx.fillRect(0, 0, canvas.width, canvas.height)
const time = 0
// 在 canvas 上绘制动画内容
function drawFrame() {
// ctx.fillStyle = `rgb(0, ${(Date.now() / 10) % 360}, 1)`; // 动态颜色
ctx.fillStyle = `rgb(255, 255, 255)` // 动态颜色
ctx.fillRect(0, 0, canvas.width, canvas.height)
// ctx.font = 'bold 50px Arial';
// ctx.fillStyle = `rgb(0, 0, 0)`;
// ctx.fillText(String(time++), 100, 100)
requestAnimationFrame(drawFrame)
}
drawFrame()
// 捕获 canvas 的视频流
const stream = canvas.captureStream(30) // 30 FPS
video_track = stream.getVideoTracks()[0] // 返回视频轨道
video_track.stop = () => {
canvas.remove()
}
video_track.onended = () => {
video_track?.stop()
}
return video_track
}
export function createSimulatedAudioTrack() {
if (audio_track) return audio_track
// @ts-ignore webkitAudioContext兼容
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
const oscillator = audioContext.createOscillator()
oscillator.frequency.setValueAtTime(0, audioContext.currentTime)
const gainNode = audioContext.createGain()
gainNode.gain.setValueAtTime(0, audioContext.currentTime)
const destination = audioContext.createMediaStreamDestination()
oscillator.connect(gainNode)
gainNode.connect(destination)
oscillator.start()
audio_track = destination.stream.getAudioTracks()[0]
audio_track.stop = () => {
audioContext.close()
}
audio_track.onended = () => {
audio_track?.stop()
}
return audio_track
}

32
src/utils/utils.ts Normal file
View File

@@ -0,0 +1,32 @@
export function click_outside(node: Node, cb: any): any {
const handle_click = (event: MouseEvent): void => {
if (
node &&
!node.contains(event.target as Node) &&
!event.defaultPrevented
) {
cb(event)
}
}
document.addEventListener('click', handle_click, true)
return {
destroy() {
document.removeEventListener('click', handle_click, true)
},
}
}
export function insertStringAt(
rawStr: string,
insertString: string,
index: number,
) {
if (index < 0 || index > rawStr.length) {
console.error('索引超出范围')
return rawStr
}
return rawStr.substring(0, index) + insertString + rawStr.substring(index)
}

269
src/utils/webrtcUtils.ts Normal file
View File

@@ -0,0 +1,269 @@
export function createPeerConnection(
pc: RTCPeerConnection,
node: {
srcObject: any
volume: number
muted: boolean
autoplay: boolean
play: () => Promise<any>
},
) {
// register some listeners to help debugging
pc.addEventListener(
'icegatheringstatechange',
() => {
console.debug(pc.iceGatheringState)
},
false,
)
pc.addEventListener(
'iceconnectionstatechange',
() => {
console.debug(pc.iceConnectionState)
},
false,
)
pc.addEventListener(
'signalingstatechange',
() => {
console.debug(pc.signalingState)
},
false,
)
// connect audio / video from server to local
pc.addEventListener('track', (evt) => {
console.debug('track event listener')
if (node && node.srcObject !== evt.streams[0]) {
console.debug('streams', evt.streams)
node.srcObject = evt.streams[0]
console.debug('node.srcOject', node.srcObject)
if (evt.track.kind === 'audio') {
node.volume = 1.0 // Ensure volume is up
node.muted = false
node.autoplay = true
// Attempt to play (needed for some browsers)
node.play().catch((e) => console.debug('Autoplay failed:', e))
}
}
})
return pc
}
export async function start(
stream: MediaStream,
pc: RTCPeerConnection,
node: {
srcObject: any
volume: number
muted: boolean
autoplay: boolean
play: () => Promise<any>
},
server_fn: any,
webrtc_id: string,
modality: 'video' | 'audio' = 'video',
on_change_cb: (msg: 'change' | 'tick') => void = () => {},
rtp_params = {},
additional_message_cb: (msg: object) => void = () => {},
reject_cb: (msg: object) => void = () => {},
) {
pc = createPeerConnection(pc, node)
const data_channel = pc.createDataChannel('text')
data_channel.onopen = () => {
console.debug('Data channel is open')
data_channel.send('handshake')
data_channel.send(JSON.stringify({ type: 'init' }))
}
data_channel.onmessage = (event) => {
console.debug('Received message:', event.data)
let event_json
try {
event_json = JSON.parse(event.data)
} catch (e) {
console.debug('Error parsing JSON')
}
if (
event.data === 'change' ||
event.data === 'tick' ||
event.data === 'stopword' ||
event_json?.type === 'warning' ||
event_json?.type === 'error' ||
event_json?.type === 'send_input' ||
event_json?.type === 'fetch_output' ||
event_json?.type === 'stopword' ||
event_json?.type === 'end_stream'
) {
on_change_cb(event_json ?? event.data)
}
additional_message_cb(event_json ?? event.data)
}
if (stream) {
stream.getTracks().forEach(async (track) => {
console.debug('Track stream callback', track)
const sender = pc.addTrack(track, stream)
const params = sender.getParameters()
const updated_params = { ...params, ...rtp_params }
await sender.setParameters(updated_params)
console.debug('sender params', sender.getParameters())
})
} else {
console.debug('Creating transceiver!')
pc.addTransceiver(modality, { direction: 'recvonly' })
}
await negotiate(pc, server_fn, webrtc_id, reject_cb)
const sender = pc.getSenders().find((s) => s.track?.kind === 'video')
console.log('sender', sender)
return [pc, data_channel] as const
}
function make_offer(
server_fn: any,
body: { sdp: string; type: RTCSdpType; webrtc_id: string },
reject_cb: (msg: object) => void = () => {},
): Promise<any> {
return new Promise((resolve, reject) => {
server_fn(body).then((data: any) => {
console.debug('data', data)
if (data?.status === 'failed') {
reject_cb(data)
console.debug('rejecting')
reject('error')
}
resolve(data)
})
})
}
async function negotiate(
pc: RTCPeerConnection,
server_fn: any,
webrtc_id: string,
reject_cb: (msg: object) => void = () => {},
): Promise<void> {
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
console.debug('Sending ICE candidate', candidate)
server_fn({
candidate: candidate.toJSON(),
webrtc_id,
type: 'ice-candidate',
}).catch((err: any) => console.error('Error sending ICE candidate:', err))
}
}
return pc
.createOffer()
.then((offer) => {
return pc.setLocalDescription(offer)
})
.then(() => {
const offer = pc.localDescription!
return make_offer(
server_fn,
{
sdp: offer.sdp,
type: offer.type,
webrtc_id,
},
reject_cb,
)
})
.then((response) => {
return response
})
.then((answer) => {
return pc.setRemoteDescription(answer)
})
}
export function stop(pc: RTCPeerConnection) {
console.debug('Stopping peer connection')
// close transceivers
if (pc.getTransceivers) {
pc.getTransceivers().forEach((transceiver) => {
if (transceiver.stop) {
transceiver.stop()
}
})
}
// close local audio / video
if (pc.getSenders()) {
pc.getSenders().forEach((sender) => {
console.log('sender', sender)
if (sender.track && sender.track.stop) sender.track.stop()
})
}
// close peer connection
setTimeout(() => {
pc.close()
}, 500)
}
export async function setupWebRTC(
stream: MediaStream,
peerConnection: RTCPeerConnection,
remoteNode: HTMLVideoElement,
) {
// Send audio-video stream to server
stream.getTracks().forEach(async (track) => {
const sender = peerConnection.addTrack(track, stream)
})
peerConnection.addEventListener('track', (evt) => {
if (remoteNode && remoteNode.srcObject !== evt.streams[0]) {
remoteNode.srcObject = evt.streams[0]
}
})
// Create data channel (needed!)
const dataChannel = peerConnection.createDataChannel('text')
// Create and send offer
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
const webrtc_id = Math.random().toString(36).substring(7)
// Send ICE candidates to server
// (especially needed when server is behind firewall)
peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
console.debug('Sending ICE candidate', candidate)
fetch('/webrtc/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
candidate: candidate.toJSON(),
webrtc_id,
type: 'ice-candidate',
}),
})
}
}
// Send offer to server
const response = await fetch('/webrtc/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sdp: offer.sdp,
type: offer.type,
webrtc_id,
}),
})
// Handle server response
const serverResponse = await response.json()
await peerConnection.setRemoteDescription(serverResponse)
return [dataChannel, webrtc_id]
}

View File

@@ -0,0 +1,90 @@
.chat-input-wrapper {
// position: absolute;
margin: 0 70px;
transition: width 0.1s ease;
}
.page-container {
height: 100%;
width: 100%;
padding: 32px;
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: center;
}
.content-container {
height: 100%;
max-width: calc(100%);
}
.video-container {
position: relative;
aspect-ratio: 9 / 16;
max-width: calc(100% - 70px - 64px);
height: 85%;
margin: 0 70px;
.local-video-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 32px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
background: #fff;
transition: all 0.3s ease;
}
.local-video-container.scaled {
top: auto;
left: 12px;
bottom: 12px;
width: calc(50% - 12px);
height: auto;
}
.remote-video-container {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 32px;
transition: all 0.3s linear;
background: #fff;
}
.local-video {
width: 100%;
height: auto;
object-fit: contain;
object-position: center center;
}
.remote-video {
width: 101%;
height: 100%;
object-fit: cover;
}
.remote-canvas {
width: 100%;
height: 100%;
}
}
.actions {
position: absolute;
z-index: 2;
left: calc(100% + 12px);
bottom: 0;
}
.chat-records-container {
height: 85%;
overflow: auto;
aspect-ratio: 9 / 16;
z-index: 1;
&.inline {
position: absolute;
bottom: 0;
right: 0;
padding: 10px;
}
}

View File

@@ -0,0 +1,193 @@
<template>
<div class="page-container" ref="wrapRef">
<div class="content-container">
<div
class="video-container"
:style="{
visibility: webcamAccessed ? 'visible' : 'hidden',
aspectRatio: remoteAspectRatio,
}"
>
<div
:class="`local-video-container ${streamState === 'open' ? 'scaled' : ''}`"
v-show="hasCamera && !cameraOff"
ref="localVideoContainerRef"
>
<video
class="local-video"
ref="localVideoRef"
autoplay
muted
playsinline
:style="{
visibility: cameraOff ? 'hidden' : 'visible',
display: !hasCamera || cameraOff ? 'none' : 'block',
}"
/>
</div>
<div class="remote-video-container" ref="remoteVideoContainerRef">
<video
v-if="!avatarType"
class="remote-video"
v-show="streamState === 'open'"
@playing="onplayingRemoteVideo"
ref="remoteVideoRef"
autoplay
playsinline
:muted="volumeMuted"
/>
<div
v-if="streamState === 'open' && showChatRecords && !isLandscape"
:class="`chat-records-container inline`"
:style="
!hasCamera || cameraOff ? 'width:80%;padding-bottom:12px;' : 'padding-bottom:12px;'
"
>
<ChatRecords
ref="chatRecordsInstanceRef"
:chatRecords="chatRecords.filter((_, index) => index >= chatRecords.length - 4)"
/>
</div>
</div>
<div class="actions">
<ActionGroup />
</div>
</div>
<template v-if="(!hasMic || micMuted) && streamState === 'open'" class="chat-input-wrapper">
<ChatInput
:replying="replying"
@interrupt="onInterrupt"
@send="onSend"
@stop="videoChatState.startWebRTC"
/>
</template>
<template v-else-if="webcamAccessed">
<ChatBtn
@start-chat="onStartChat"
:audio-source-callback="audioSourceCallback"
:streamState="streamState"
wave-color="#7873F6"
/>
</template>
</div>
<div
v-if="streamState === 'open' && showChatRecords && isLandscape"
class="chat-records-container"
>
<ChatRecords ref="chatRecordsInstanceRef" :chatRecords="chatRecords" />
</div>
</div>
</template>
<script setup lang="ts">
import ActionGroup from '@/components/ActionGroup.vue';
import ChatBtn from '@/components/ChatBtn.vue';
import ChatInput from '@/components/ChatInput.vue';
import ChatRecords from '@/components/ChatRecords.vue';
import { useVideoChatStore } from '@/store';
import { useVisionStore } from '@/store/vision';
import { storeToRefs } from 'pinia';
import { onMounted, ref, useTemplateRef } from 'vue';
const visionState = useVisionStore();
const videoChatState = useVideoChatStore();
const wrapRef = ref<HTMLDivElement>();
const localVideoContainerRef = ref<HTMLDivElement>();
const remoteVideoContainerRef = ref<HTMLDivElement>();
const localVideoRef = ref<HTMLVideoElement>();
const remoteVideoRef = ref<HTMLVideoElement>();
const remoteAspectRatio = ref('9 / 16');
const onplayingRemoteVideo = () => {
if (remoteVideoRef.value) {
remoteAspectRatio.value = `${remoteVideoRef.value.videoWidth} / ${remoteVideoRef.value.videoHeight}`;
}
};
const audioSourceCallback = () => {
return videoChatState.localStream;
};
onMounted(() => {
const wrapperRef = wrapRef.value;
visionState.wrapperRef = wrapperRef;
wrapperRef!.getBoundingClientRect();
wrapperRect.value.width = wrapperRef!.clientWidth;
wrapperRect.value.height = wrapperRef!.clientHeight;
visionState.isLandscape = wrapperRect.value.width > wrapperRect.value.height;
console.log(wrapperRect);
visionState.remoteVideoContainerRef = remoteVideoContainerRef.value;
visionState.localVideoContainerRef = localVideoContainerRef.value;
visionState.localVideoRef = localVideoRef.value;
visionState.remoteVideoRef = remoteVideoRef.value;
visionState.wrapperRef = wrapRef.value;
});
const {
hasCamera,
hasMic,
micMuted,
cameraOff,
webcamAccessed,
streamState,
avatarType,
volumeMuted,
replying,
showChatRecords,
chatRecords,
} = storeToRefs(videoChatState);
const { wrapperRect, isLandscape } = storeToRefs(visionState);
function onStartChat() {
videoChatState.startWebRTC().then(() => {
initChatDataChannel();
});
}
function initChatDataChannel() {
if (!videoChatState.chatDataChannel) return;
videoChatState.chatDataChannel.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.type === 'chat') {
const index = videoChatState.chatRecords.findIndex((item) => {
return item.id === data.id;
});
if (index !== -1) {
const item = videoChatState.chatRecords[index];
item.message += data.message;
videoChatState.chatRecords.splice(index, 1, item);
videoChatState.chatRecords = [...videoChatState.chatRecords];
} else {
videoChatState.chatRecords = [
...videoChatState.chatRecords,
{
id: data.id,
role: data.role || 'human', // TODO: 默认值测试后续删除
message: data.message,
},
];
}
} else if (data.type === 'avatar_end') {
videoChatState.replying = false;
}
});
}
function onInterrupt() {
if (videoChatState.chatDataChannel) {
videoChatState.chatDataChannel.send(JSON.stringify({ type: 'stop_chat' }));
}
}
const chatRecordsInstanceRef = useTemplateRef<any>('chatRecordsInstanceRef');
function onSend(message: string) {
if (!message) return;
if (!videoChatState.chatDataChannel) return;
videoChatState.chatDataChannel.send(JSON.stringify({ type: 'chat', data: message }));
videoChatState.replying = true;
chatRecordsInstanceRef.value?.scrollToBottom();
}
</script>
<style lang="less" scoped>
@import './index.less';
</style>

30
stylelint.config.js Normal file
View File

@@ -0,0 +1,30 @@
export default {
extends: ['stylelint-config-standard'],
// stylelint不识别:global, 添加selector-pseudo-class-no-unknown忽略:global
rules: {
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
// 对:global处理有问题, 所以关掉该规则
'no-descending-specificity': null,
// 要求css的选择器名称是kebab-case, 历史代码很多是驼峰的, 所以关掉该规则
'selector-class-pattern': null,
// 该规则不允许供应商前缀值; 而最多显示几行时需要display: -webkit-box; 所以忽略'box'
'value-no-vendor-prefix': [true, { ignoreValues: ['box'] }],
'custom-property-pattern': '^([a-zA-Z0-9]|-|_)*$',
'rule-empty-line-before': null,
'declaration-empty-line-before': null,
'allow-empty-input': true,
// 采用系统默认字体
'font-family-no-missing-generic-family-keyword': null,
},
overrides: [
{
files: ['**/*.less'],
customSyntax: 'postcss-less',
},
],
};

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "__tests__"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

70
vite.config.ts Normal file
View File

@@ -0,0 +1,70 @@
import legacyPlugin from '@vitejs/plugin-legacy'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// import mkcert from 'vite-plugin-mkcert'
import { join } from 'path'
// server of your OpenAvatarChat
const serverIP = '127.0.0.1'
const serverPort = '8282'
// https://vitejs.dev/config/
export default defineConfig({
base: './',
build: {
rollupOptions: {
// input: {
// index: resolve(__dirname, 'index.html'),
// cropImage: resolve(__dirname, 'cropImage.html')
// },
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
},
server: {
// host: '0.0.0.0',
// https: true,
// port: 443,
proxy: {
'/download': {
target: `https://${serverIP}:${serverPort}`,
changeOrigin: true,
secure: false,
},
'/openavatarchat': {
target: `https://${serverIP}:${serverPort}`,
changeOrigin: true,
secure: false,
},
'/webrtc/offer': {
target: `https://${serverIP}:${serverPort}`,
changeOrigin: true,
secure: false,
},
'/ws': {
target: `wss://${serverIP}:${serverPort}`,
ws: true,
rewriteWsOrigin: true,
secure: false,
},
},
},
plugins: [
vue(),
// 本地开发如果需要https才能走通接口的话则需要开启mkcert,并且开启mkcert需要sudo权限
// mkcert({
// source: 'coding'
// }),
legacyPlugin({
modernPolyfills: true,
}),
],
resolve: {
alias: {
'@': join(__dirname, 'src'),
},
},
})