项目初始化

This commit is contained in:
libingxiang 2026-06-11 13:46:14 +08:00
commit db73bcd2ae
19 changed files with 10074 additions and 0 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
registry = https://registry.npmmirror.com

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数字人交互平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5920
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "digital-human-merged",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@bddh/starling-realtime-client": "^2.0.9",
"crypto-js": "^4.2.0",
"element-plus": "^2.9.7",
"uuid": "^9.0.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.97.3",
"terser": "^5.48.0",
"typescript": "^5.7.3",
"vite": "^5.4.11",
"vitest": "^4.1.8"
}
}

1298
src/App.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,907 @@
<template>
<div class="subtitle-panel">
<!-- 字幕模式下拉框 -->
<div class="subtitle-header">
<div class="dropdown-wrapper" :class="{ open: dropdownOpen }">
<button class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-label">{{ modeLabel }}</span>
<svg
class="dropdown-arrow"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<Transition name="dropdown">
<div v-if="dropdownOpen" class="dropdown-menu">
<button
v-for="opt in modeOptions"
:key="opt.value"
class="dropdown-item"
:class="{ active: currentMode === opt.value }"
@click="selectMode(opt.value)"
>
<span class="item-icon">{{ opt.icon }}</span>
<span class="item-label">{{ opt.label }}</span>
<svg
v-if="currentMode === opt.value"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
</Transition>
</div>
</div>
<!-- 模式1仅AI字幕 -->
<div
v-if="currentMode === 'ai-only'"
class="subtitle-content ai-only-mode"
ref="scrollContainerRef"
>
<div v-if="aiSubtitle" class="ai-subtitle-bar" ref="aiSubtitleBarRef">
<div
class="ai-subtitle-text"
ref="aiSubtitleTextRef"
:style="{ fontSize: aiFontSize + 'px' }"
>
{{ aiSubtitle }}
</div>
<!-- 滚动指示器 -->
<div
v-if="aiTextOverflow"
class="scroll-hint"
@click="scrollAIToBottom"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
<div v-else class="subtitle-placeholder">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
等待AI回复...
</div>
</div>
<!-- 模式2对话气泡 -->
<div
v-if="currentMode === 'bubble'"
class="subtitle-content bubble-mode"
ref="scrollContainerRef"
>
<div v-if="messages.length === 0" class="subtitle-placeholder">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
等待对话开始...
</div>
<div class="chat-list" ref="chatListRef">
<div
v-for="msg in messages"
:key="msg.id"
:class="[
'chat-bubble',
msg.role === 'user' ? 'bubble-user' : 'bubble-ai',
]"
>
<div class="bubble-body">
<div
class="bubble-text"
:class="{
collapsed: isCollapsed(msg.id) && isLongText(msg.content),
'text-user': msg.role === 'user',
'text-ai': msg.role === 'ai',
}"
:ref="(el) => setMsgRef(msg.id, el)"
>
{{ msg.content }}
</div>
<button
v-if="isLongText(msg.content)"
class="expand-btn"
@click="toggleCollapse(msg.id)"
>
{{ isCollapsed(msg.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(msg.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- 模式3合并气泡 -->
<div
v-if="currentMode === 'merged'"
class="subtitle-content merged-mode"
ref="scrollContainerRef"
>
<div v-if="messages.length === 0" class="subtitle-placeholder">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
等待对话开始...
</div>
<div class="merged-list" ref="mergedListRef">
<div
v-for="(pair, idx) in pairedMessages"
:key="idx"
class="merged-bubble"
>
<div v-if="pair.user" class="merged-user-section">
<div
class="merged-text text-user"
:class="{
collapsed:
isCollapsed(pair.user.id) && isLongText(pair.user.content),
}"
>
{{ pair.user.content }}
</div>
<button
v-if="isLongText(pair.user.content)"
class="expand-btn"
@click="toggleCollapse(pair.user.id)"
>
{{ isCollapsed(pair.user.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(pair.user.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<div v-if="pair.ai" class="merged-ai-section">
<div
class="merged-text text-ai"
:class="{
collapsed:
isCollapsed(pair.ai.id) && isLongText(pair.ai.content),
}"
>
{{ pair.ai.content }}
</div>
<button
v-if="isLongText(pair.ai.content)"
class="expand-btn"
@click="toggleCollapse(pair.ai.id)"
>
{{ isCollapsed(pair.ai.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(pair.ai.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import type { ChatMessage, SubtitleMode } from "@/types";
const props = defineProps<{
mode: SubtitleMode;
aiSubtitle: string;
userSubtitle: string;
messages: ChatMessage[];
}>();
const emit = defineEmits<{
"update:mode": [value: SubtitleMode];
}>();
const currentMode = computed({
get: () => props.mode,
set: (val: SubtitleMode) => emit("update:mode", val),
});
const dropdownOpen = ref(false);
// ref .subtitle-content
const scrollContainerRef = ref<HTMLElement | null>(null);
const aiSubtitleBarRef = ref<HTMLElement | null>(null);
const aiSubtitleTextRef = ref<HTMLElement | null>(null);
// ===== AI =====
const aiFontSize = ref(18);
const aiTextOverflow = ref(false);
// AI
const computeAIFontSize = () => {
const text = props.aiSubtitle;
if (!text) {
aiFontSize.value = 18;
return;
}
const len = text.length;
if (len <= 50) aiFontSize.value = 18;
else if (len <= 100) aiFontSize.value = 16;
else if (len <= 200) aiFontSize.value = 14;
else if (len <= 400) aiFontSize.value = 13;
else aiFontSize.value = 12;
};
// AI
const checkAIOverflow = () => {
nextTick(() => {
const bar = aiSubtitleBarRef.value;
const textEl = aiSubtitleTextRef.value;
if (bar && textEl) {
aiTextOverflow.value = textEl.scrollHeight > bar.clientHeight + 4;
}
});
};
const scrollAIToBottom = () => {
if (aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "smooth",
});
}
};
// ===== / =====
const COLLAPSE_THRESHOLD = 120; // 120
const collapsedIds = ref<Set<string>>(new Set());
const msgRefs = new Map<string, HTMLElement>();
const isLongText = (text: string): boolean => text.length > COLLAPSE_THRESHOLD;
const isCollapsed = (id: string): boolean => collapsedIds.value.has(id);
const toggleCollapse = (id: string) => {
if (collapsedIds.value.has(id)) {
collapsedIds.value.delete(id);
} else {
collapsedIds.value.add(id);
}
//
collapsedIds.value = new Set(collapsedIds.value);
};
const setMsgRef = (id: string, el: any) => {
if (el) msgRefs.set(id, el as HTMLElement);
};
// ===== =====
const modeOptions = [
{ value: "ai-only" as SubtitleMode, label: "AI字幕", icon: "💬" },
{ value: "bubble" as SubtitleMode, label: "对话气泡", icon: "💭" },
{ value: "merged" as SubtitleMode, label: "合并气泡", icon: "📋" },
];
const modeLabel = computed(() => {
return (
modeOptions.find((o) => o.value === currentMode.value)?.label || "AI字幕"
);
});
const selectMode = (val: SubtitleMode) => {
currentMode.value = val;
dropdownOpen.value = false;
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".dropdown-wrapper")) {
dropdownOpen.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickOutside);
});
// ===== =====
const pairedMessages = computed(() => {
const pairs: { user: ChatMessage | null; ai: ChatMessage | null }[] = [];
let currentPair: { user: ChatMessage | null; ai: ChatMessage | null } = {
user: null,
ai: null,
};
for (const msg of props.messages) {
if (msg.role === "user") {
if (currentPair.user) {
pairs.push(currentPair);
currentPair = { user: msg, ai: null };
} else {
currentPair.user = msg;
}
} else {
currentPair.ai = msg;
pairs.push(currentPair);
currentPair = { user: null, ai: null };
}
}
if (currentPair.user || currentPair.ai) {
pairs.push(currentPair);
}
return pairs;
});
// ===== =====
//
watch(
() => props.mode,
() => {
computeAIFontSize();
nextTick(() => {
checkAIOverflow();
scrollToBottom();
// AI
if (props.mode === "ai-only" && aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "instant" as ScrollBehavior,
});
}
});
},
);
// AI + +
watch(
() => props.aiSubtitle,
() => {
computeAIFontSize();
checkAIOverflow();
// AI
nextTick(() => {
if (aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "smooth",
});
}
});
},
);
// +
watch(
() => props.messages.length,
(newLen, oldLen) => {
//
if (newLen > (oldLen ?? 0)) {
for (let i = oldLen ?? 0; i < newLen; i++) {
const msg = props.messages[i];
if (msg && isLongText(msg.content)) {
collapsedIds.value.add(msg.id);
}
}
collapsedIds.value = new Set(collapsedIds.value);
}
nextTick(() => {
scrollToBottom();
});
},
);
//
watch(
() => {
const msgs = props.messages;
if (msgs.length === 0) return "";
//
return msgs[msgs.length - 1].content;
},
() => {
nextTick(() => {
scrollToBottom();
});
},
);
/**
* 将对话列表滚动到底部
* 兼容不同内容量内容少时无需滚动内容超出可见区域时平滑滚动到底部
*/
const scrollToBottom = () => {
const el = scrollContainerRef.value;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: "smooth",
});
};
</script>
<style scoped lang="scss">
.subtitle-panel {
display: flex;
flex-direction: column;
height: 100%;
}
/* ===== 下拉框 ===== */
.subtitle-header {
display: flex;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
flex-shrink: 0;
}
.dropdown-wrapper {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.18);
}
.dropdown-wrapper.open & {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(108, 140, 255, 0.4);
}
}
.dropdown-arrow {
transition: transform 0.2s ease;
.dropdown-wrapper.open & {
transform: rotate(180deg);
}
}
.dropdown-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
min-width: 180px;
background: rgba(20, 25, 50, 0.97);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 6px;
z-index: 100;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.5);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: none;
background: none;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
&.active {
background: rgba(108, 140, 255, 0.15);
color: #6c8cff;
}
.item-icon {
font-size: 14px;
}
.item-label {
flex: 1;
text-align: left;
}
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
/* ===== 字幕内容 ===== */
.subtitle-content {
flex: 1;
min-height: 0;
max-height: calc(30vh - 60px);
overflow-y: auto;
animation: contentFade 0.3s ease;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
}
}
@keyframes contentFade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.subtitle-placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
/* ===== 模式1AI字幕 ===== */
.ai-only-mode {
display: flex;
align-items: flex-end;
padding-bottom: 4px;
}
.ai-subtitle-bar {
width: 100%;
max-height: 100%;
overflow-y: auto;
background: linear-gradient(
135deg,
rgba(108, 140, 255, 0.06) 0%,
rgba(79, 110, 247, 0.03) 100%
);
border: 1px solid rgba(108, 140, 255, 0.06);
border-radius: 8px;
padding: 8px 12px;
backdrop-filter: blur(4px);
transition: all 0.3s ease;
position: relative;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(108, 140, 255, 0.15);
border-radius: 2px;
}
}
.ai-subtitle-text {
line-height: 1.6;
color: #ffffff;
word-break: break-word;
font-weight: 400;
font-size: 14px;
letter-spacing: 0.2px;
transition: font-size 0.3s ease;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.scroll-hint {
position: sticky;
bottom: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(108, 140, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
margin: 8px auto 0;
transition: all 0.2s;
&:hover {
background: rgba(108, 140, 255, 0.5);
transform: translateX(-50%) scale(1.1);
}
}
/* ===== 模式2对话气泡 ===== */
.bubble-mode {
.chat-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px 0;
}
}
.chat-bubble {
animation: bubbleSlide 0.35s ease;
&.bubble-user {
.bubble-body {
background: rgba(240, 242, 255, 0.92);
border-color: rgba(79, 110, 247, 0.12);
border-radius: 14px 14px 4px 14px;
margin-left: 24px;
}
}
&.bubble-ai {
.bubble-body {
background: rgba(20, 30, 50, 0.85);
border-color: rgba(52, 211, 153, 0.12);
border-radius: 14px 14px 14px 4px;
margin-right: 24px;
}
}
}
@keyframes bubbleSlide {
from {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bubble-body {
padding: 8px 12px;
border: 1px solid;
min-width: 0;
}
.bubble-text {
font-size: 14px;
line-height: 1.6;
word-break: break-word;
overflow: hidden;
transition: max-height 0.35s ease;
&.text-user {
color: #1a1a1a;
text-shadow: none;
}
&.text-ai {
color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
&.collapsed {
max-height: 4.8em;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2em;
background: linear-gradient(transparent, rgba(10, 14, 30, 0.5));
pointer-events: none;
border-radius: 0 0 8px 8px;
}
}
}
/* ===== 展开/收起按钮 ===== */
.expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 3px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
}
/* ===== 模式3合并气泡 ===== */
.merged-mode {
.merged-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px 0;
}
}
.merged-bubble {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
animation: bubbleSlide 0.35s ease;
}
.merged-user-section {
padding: 8px 12px;
border-bottom: 1px solid rgba(79, 110, 247, 0.08);
}
.merged-ai-section {
padding: 8px 12px;
}
.merged-text {
font-size: 14px;
line-height: 1.6;
word-break: break-word;
overflow: hidden;
transition: max-height 0.35s ease;
&.text-user {
color: #303030ab;
text-shadow: none;
}
&.text-ai {
color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
&.collapsed {
max-height: 4.8em;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2em;
background: linear-gradient(transparent, rgba(10, 14, 30, 0.5));
pointer-events: none;
}
}
}
</style>

View File

@ -0,0 +1,833 @@
import { ref, onBeforeUnmount, watch } from "vue";
import { v4 as uuidv4 } from "uuid";
import RealTimeHuman from "@bddh/starling-realtime-client";
import { checkPlayUnMute } from "@/utils/audioUtils";
import { SilenceDetector } from "@/utils/SilenceDetector";
import { TokenManager } from "@/utils/tokenManager";
import type { ChatMessage, SubtitleMode } from "@/types";
const DEFAULT_TEXT =
"我是虚拟数字人,这是我的开场白,你可以在左侧输入框内输入想要让我播报的内容";
const OPENING_TTS_DELAY_MS = 200;
const SILENCE_TIMEOUT_MS = 5000;
// 最短有效语音时长(秒),低于此时长视为无效输入
const MIN_VALID_DURATION_SEC = 0.5;
// 播报超时保护毫秒如果FINISHED回调未触发自动恢复状态
const BROADCAST_TIMEOUT_MS = 60000;
const APPID = "i-sfkmd7h8ex6nd";
const APP_KEY = "hs6b9nu9b080ap9hswgp";
/**
*
* ASR结果
*/
const hasSubstantialContent = (text: string): boolean => {
const cleaned = text.replace(
/[\s\u3000.,!?;:,。!?;:、…—\-_·""''()\[\]【】《》<>]/g,
"",
);
return cleaned.length > 0;
};
enum ReadyState {
UNINSTANTIATED = -1,
CONNECTING = 0,
OPEN = 1,
CLOSING = 2,
CLOSED = 3,
}
/**
*
* - idle: 空闲
* - recording: 正在录音BTN模式按住/RTC模式ASR确认有效语音
* - processing: 语音采集完成ASR正在识别
* - waiting: 等待AI回复
* - broadcasting: 数字人正在播报
*
*
* idle recording (BTN模式按下 / RTC模式ASR确认有效语音内容)
* recording processing (BTN松手 / RTC静音超时 / ASR结果)
* recording idle ()
* processing waiting ()
* processing idle ( / ASR错误 / )
* waiting broadcasting (AI回复到达)
* broadcasting idle ( / / )
*
*
* - idle状态下仅当ASR返回包含实质内容的QUERY时才转换到recording
* - /ASR结果在idle状态下被忽略
* - SilenceDetector声音回调不触发状态转换
*/
export type PickPhase =
| "idle"
| "recording"
| "processing"
| "waiting"
| "broadcasting";
/** 合法状态转换映射表 */
const VALID_TRANSITIONS: Record<PickPhase, PickPhase[]> = {
idle: ["recording"],
recording: ["processing", "idle"],
processing: ["waiting", "idle"],
waiting: ["broadcasting", "idle"],
broadcasting: ["idle"],
};
export function useDigitalHuman(options?: {
onUserQueryComplete?: (text: string) => void;
}) {
const humanInstanceRef = ref<any>(null);
const realTimeVideoReady = ref(false);
const wsConnected = ref(false);
const videoIsMuted = ref(false);
const checkOver = ref(false);
const openingPlayed = ref(false);
const audioMode = ref<"btn" | "rtc" | "">("");
const isRecording = ref(false);
const canTransfer = ref(false);
const isMuted = ref(true);
const enableInterruptRef = ref(true);
const pullAudioFromRTC = ref(false);
const isConnected = ref(false);
// ===== 拾音状态机 =====
const pickPhase = ref<PickPhase>("idle");
const setPickPhase = (phase: PickPhase, reason: string) => {
const prev = pickPhase.value;
// 状态转换守卫:检查是否为合法转换
const allowed = VALID_TRANSITIONS[prev];
if (allowed && !allowed.includes(phase)) {
console.warn(
`[状态机] 非法状态转换: ${prev}${phase} (${reason}),已阻止`,
);
return;
}
pickPhase.value = phase;
console.info(`[状态机] pickPhase: ${prev}${phase} (${reason})`);
};
// ===== 播报状态追踪 =====
// 数字人是否正在播报TTS语音输出中
const isBroadcasting = ref(false);
// 字幕相关
const subtitleMode = ref<SubtitleMode>("ai-only");
const currentAISubtitle = ref("");
const currentUserSubtitle = ref("");
const chatMessages = ref<ChatMessage[]>([]);
let streamingAIId: string | null = null;
let streamingUserId: string | null = null;
// Token 配置:使用 TokenManager 自动生成与刷新
const tokenManager = new TokenManager(APPID, APP_KEY);
const token = ref(tokenManager.getToken());
// Token 更新时同步到 ref
tokenManager.onTokenRefreshed((tokenInfo) => {
token.value = tokenInfo.token;
});
// 静音检测器RTC模式专用
const silenceDetector = new SilenceDetector({
volumeThreshold: 8,
silenceDurationMs: SILENCE_TIMEOUT_MS,
});
// 录音时长追踪BTN模式
let recordStartTime = 0;
// 播报超时保护计时器
let broadcastTimeoutId: ReturnType<typeof setTimeout> | null = null;
const clearBroadcastTimeout = () => {
if (broadcastTimeoutId !== null) {
clearTimeout(broadcastTimeoutId);
broadcastTimeoutId = null;
}
};
const startBroadcastTimeout = () => {
clearBroadcastTimeout();
broadcastTimeoutId = setTimeout(() => {
console.warn("[播报] 超时保护触发FINISHED回调未收到自动恢复状态");
isBroadcasting.value = false;
if (pickPhase.value === "broadcasting") {
setPickPhase("idle", "播报超时保护,恢复空闲");
if (audioMode.value === "rtc") {
resumeAutoPickup();
}
}
}, BROADCAST_TIMEOUT_MS);
};
/**
* +
*/
const enterBroadcasting = () => {
isBroadcasting.value = true;
setPickPhase("broadcasting", "数字人开始播报");
startBroadcastTimeout();
// RTC模式播报期间禁用拾音
if (audioMode.value === "rtc") {
humanInstanceRef.value?.muteMicrophone?.(true);
isMuted.value = true;
canTransfer.value = false;
silenceDetector.pause();
console.info("[播报] RTC模式麦克风已静音静音检测器已暂停");
}
console.info("[播报] 开始播报等待FINISHED信号");
};
/**
*
*/
const isValidVoiceInput = (text: string, durationSec: number): boolean => {
const cleaned = text.replace(
/[\s\u3000.,!?;:,。!?;:、…—\-_·""''()\[\]【】《》<>]/g,
"",
);
if (!cleaned) {
console.info("[语音有效性] 文本为空或仅含标点/空白,判定为无效输入");
return false;
}
if (durationSec < MIN_VALID_DURATION_SEC) {
console.info(
`[语音有效性] 录音时长 ${durationSec.toFixed(2)}s 不足 ${MIN_VALID_DURATION_SEC}s判定为无效输入`,
);
return false;
}
return true;
};
/**
*
*/
const handleInvalidVoiceInput = () => {
console.info("[语音有效性] 终止当前交互流程,不进入等待回复状态");
currentUserSubtitle.value = "";
// 先暂停静音检测器,防止恢复拾音时产生竞争
silenceDetector.pause();
setPickPhase("idle", "无效语音输入,恢复空闲");
if (audioMode.value === "rtc") {
resumeAutoPickup();
}
};
/**
* RTC模式下恢复拾音
*/
const onBroadcastFinished = () => {
clearBroadcastTimeout();
isBroadcasting.value = false;
console.info("[播报] 数字人播报完成");
// 仅在 broadcasting 状态下恢复空闲,避免状态错乱
if (pickPhase.value === "broadcasting") {
setPickPhase("idle", "播报完成,恢复空闲");
if (audioMode.value === "rtc") {
resumeAutoPickup();
}
}
};
const addChatMessage = (
role: "user" | "ai",
content: string,
completed: boolean = false,
): ChatMessage => {
const msg: ChatMessage = {
id: uuidv4(),
role,
content,
timestamp: Date.now(),
completed,
};
if (!completed) {
if (role === "ai") {
streamingAIId = msg.id;
} else {
streamingUserId = msg.id;
}
}
chatMessages.value = [...chatMessages.value, msg];
return msg;
};
const updateStreamingMessage = (
role: "user" | "ai",
content: string,
completed: boolean,
) => {
const targetId = role === "ai" ? streamingAIId : streamingUserId;
if (!targetId) return;
const idx = chatMessages.value.findIndex((m) => m.id === targetId);
if (idx === -1) return;
const updated: ChatMessage = {
...chatMessages.value[idx],
content,
completed,
};
chatMessages.value = [
...chatMessages.value.slice(0, idx),
updated,
...chatMessages.value.slice(idx + 1),
];
if (completed) {
if (role === "ai") streamingAIId = null;
else streamingUserId = null;
}
};
const startNewAIReply = () => {
currentAISubtitle.value = "";
streamingAIId = null;
};
const startNewUserQuery = () => {
currentUserSubtitle.value = "";
streamingUserId = null;
};
const handleDigitalHumanCallback = async (data: any) => {
const { status, content } = data;
console.info("[DH Callback]", status, content);
if (status === "DH_LIB_MESSAGE") {
switch (content.action) {
case "DOWN_SUBTITLE": {
const {
content: subtitleContent,
completed,
type,
} = JSON.parse(content.body);
if (type === "QUERY") {
// 播报期间忽略ASR结果双重守卫麦克风已静音但防止残余回调
if (pickPhase.value === "broadcasting") {
console.info("[状态机] 播报中收到ASR结果忽略");
break;
}
// waiting 状态下忽略ASR结果正在等LLM回复不应接受新输入
if (pickPhase.value === "waiting") {
console.info("[状态机] 等待AI回复中忽略QUERY字幕");
break;
}
// idle 状态下收到 QUERY必须验证内容有实质才允许状态转换
// 环境噪音可能触发 ASR 产生空白/标点内容的 QUERY不应进入识别
if (pickPhase.value === "idle") {
const hasSubstance = hasSubstantialContent(subtitleContent);
if (!hasSubstance) {
console.info(
"[状态机] idle状态下QUERY内容无实质空白/标点),忽略",
);
break;
}
// 内容有实质:用户已开始有效说话
// 暂停静音检测器避免竞争,然后进入 recording 再到 processing
if (audioMode.value === "rtc") {
silenceDetector.pause();
}
console.info(
"[状态机] idle状态下收到有效ASR结果用户已开始说话",
);
setPickPhase("recording", "ASR确认检测到有效语音");
}
if (pickPhase.value === "recording") {
setPickPhase("processing", "收到ASR识别结果");
}
if (streamingUserId && !completed) {
currentUserSubtitle.value = subtitleContent;
updateStreamingMessage("user", subtitleContent, completed);
} else if (!streamingUserId) {
startNewUserQuery();
currentUserSubtitle.value = subtitleContent;
addChatMessage("user", subtitleContent, completed);
} else {
currentUserSubtitle.value = subtitleContent;
updateStreamingMessage("user", subtitleContent, completed);
}
if (completed) {
console.info("完整询问内容:", subtitleContent);
const recordDuration =
recordStartTime > 0
? (Date.now() - recordStartTime) / 1000
: MIN_VALID_DURATION_SEC;
recordStartTime = 0;
if (!isValidVoiceInput(subtitleContent, recordDuration)) {
handleInvalidVoiceInput();
} else {
setPickPhase("waiting", "有效语音输入完成等待AI回复");
options?.onUserQueryComplete?.(subtitleContent);
}
}
}
if (type === "REPLY") {
if (streamingAIId && !completed) {
currentAISubtitle.value = subtitleContent;
updateStreamingMessage("ai", subtitleContent, completed);
} else if (!streamingAIId) {
startNewAIReply();
currentAISubtitle.value = subtitleContent;
addChatMessage("ai", subtitleContent, completed);
} else {
currentAISubtitle.value = subtitleContent;
updateStreamingMessage("ai", subtitleContent, completed);
}
// REPLY字幕完成 ≠ 播报完成,不在此处恢复拾音
// 拾音恢复由 TEXT_RENDER 的 FINISHED 回调触发
if (completed) {
console.info("[字幕] AI回复字幕完成等待播报音频结束");
}
}
break;
}
case "AUDIO_QUERY_INTERRUPT":
console.info("停止发送音频");
if (
pickPhase.value === "recording" ||
pickPhase.value === "processing"
) {
setPickPhase("idle", "音频被中断,恢复空闲");
}
break;
case "EMPTY_ASR_RESULT":
console.info("空ASR结果判定为无效语音输入");
if (pickPhase.value === "broadcasting") {
console.info("[状态机] 播报中收到空ASR结果忽略");
break;
}
// 仅在 recording/processing 状态下才处理为无效输入
// idle 状态下收到空ASR结果说明环境噪音触发不应改变状态
if (
pickPhase.value !== "recording" &&
pickPhase.value !== "processing"
) {
console.info(`[状态机] 当前状态 ${pickPhase.value}忽略空ASR结果`);
break;
}
handleInvalidVoiceInput();
break;
case "ASR_ERROR":
console.info("ASR错误:", content);
if (pickPhase.value === "broadcasting") {
console.info("[状态机] 播报中收到ASR错误忽略");
break;
}
// 仅在 recording/processing 状态下才处理为无效输入
if (
pickPhase.value !== "recording" &&
pickPhase.value !== "processing"
) {
console.info(`[状态机] 当前状态 ${pickPhase.value}忽略ASR错误`);
break;
}
handleInvalidVoiceInput();
break;
default:
break;
}
}
if (status === "DH_LIB_FULL_STATUS") {
const { type, action } = content;
if (type === "rtcState") {
if (action === "remotevideoon") {
realTimeVideoReady.value = true;
checkPlayUnMuteFun();
}
if (action === "localVideoMuted" && content.body) {
videoIsMuted.value = true;
}
}
if (type === "wsState") {
if (content.readyState === ReadyState.OPEN) {
wsConnected.value = true;
isConnected.value = true;
} else if (
content.readyState === ReadyState.CLOSED ||
content.readyState === ReadyState.CLOSING
) {
wsConnected.value = false;
isConnected.value = false;
}
}
}
};
const checkPlayUnMuteFun = async () => {
const result = await checkPlayUnMute();
videoIsMuted.value = !result;
checkOver.value = true;
};
const destroyCurrentInstance = async () => {
if (!humanInstanceRef.value) return;
try {
await humanInstanceRef.value.destroy();
} catch (error) {
console.error("销毁旧实例失败:", error);
} finally {
humanInstanceRef.value = null;
isConnected.value = false;
}
};
const connect = async (mode: "btn" | "rtc" | "" = "btn") => {
await destroyCurrentInstance();
openingPlayed.value = false;
audioMode.value = mode;
pullAudioFromRTC.value = mode === "rtc";
pickPhase.value = "idle";
isBroadcasting.value = false;
// 获取最新 Token 并启动自动刷新
token.value = tokenManager.getToken();
tokenManager.startAutoRefresh();
const config: any = {
token: token.value,
wrapperId: "human-wrapper",
connectParams: {
ttsPer: "CAP_4189",
figureId: "3999826",
resolutionHeight: 1920,
resolutionWidth: 1080,
inactiveDisconnectSec: 300,
},
renderParams: {
closeLog: true,
autoChromaKey: false,
fullStatus: true,
},
onDigitalHumanCallback: handleDigitalHumanCallback,
};
if (mode !== "") {
config.connectParams.pullAudioFromRtc = mode === "rtc";
config.connectParams.pickAudioMode =
mode === "btn" ? "pressButton" : "free";
config.connectParams.enableInterrupt = enableInterruptRef.value;
}
humanInstanceRef.value = new RealTimeHuman(config);
humanInstanceRef.value.createServer();
};
const disconnect = async () => {
silenceDetector.stop();
clearBroadcastTimeout();
tokenManager.stopAutoRefresh();
pickPhase.value = "idle";
isBroadcasting.value = false;
currentAISubtitle.value = "";
currentUserSubtitle.value = "";
chatMessages.value = [];
streamingAIId = null;
streamingUserId = null;
isRecording.value = false;
canTransfer.value = false;
isMuted.value = true;
realTimeVideoReady.value = false;
wsConnected.value = false;
checkOver.value = false;
videoIsMuted.value = false;
openingPlayed.value = false;
try {
await humanInstanceRef.value?.destroy?.();
} catch (error) {
console.error("销毁发生错误:", error);
} finally {
humanInstanceRef.value = null;
isConnected.value = false;
}
};
const textRender = (text: string) => {
const commandId = uuidv4();
enterBroadcasting();
humanInstanceRef.value?.sendMessage?.(
{
action: "TEXT_RENDER",
body: text,
requestId: commandId,
},
({ action }: { action: string }) => {
console.info(`[播报] TEXT_RENDER callback: ${action}`);
if (action === "FINISHED") {
onBroadcastFinished();
}
},
);
};
const streamTextRender = (texts: string[], requestId?: string) => {
const commandId = requestId || uuidv4();
enterBroadcasting();
texts.forEach((text, index) => {
humanInstanceRef.value?.textStreamRender?.({
body: JSON.stringify({
first: index === 0,
last: index === texts.length - 1,
text,
}),
requestId: commandId,
});
});
};
const interrupt = async () => {
console.info("发送打断");
clearBroadcastTimeout();
await humanInstanceRef.value?.interrupt?.();
// 打断后播报结束,恢复状态
if (isBroadcasting.value) {
isBroadcasting.value = false;
console.info("[播报] 被打断,播报终止");
}
if (pickPhase.value === "broadcasting") {
setPickPhase("idle", "播报被打断,恢复空闲");
if (audioMode.value === "rtc") {
resumeAutoPickup();
}
}
console.info("打断生效");
};
const muteHuman = () => humanInstanceRef.value?.muteHuman?.();
const unMuteHuman = () => humanInstanceRef.value?.unMuteHuman?.();
const playHuman = () => humanInstanceRef.value?.playHuman?.();
const pauseHuman = () => humanInstanceRef.value?.pauseHuman?.();
const changeMicrophoneState = (mute: boolean) => {
humanInstanceRef.value?.muteMicrophone?.(mute);
canTransfer.value = !mute;
isMuted.value = mute;
if (audioMode.value === "rtc") {
if (mute) {
silenceDetector.pause();
} else {
const resumed = silenceDetector.resume();
if (!resumed) {
startSilenceDetection();
}
}
}
};
/**
* RTC模式
*/
const startSilenceDetection = async () => {
if (audioMode.value !== "rtc") return;
const started = await silenceDetector.start(
() => {
// 状态守卫:仅在 recording 状态下才触发识别流程
// idle 状态下静音超时不应自动进入识别(用户未主动开始录入)
if (pickPhase.value !== "recording") {
console.info(
`[自动拾音] 当前状态为 ${pickPhase.value},忽略静音超时回调`,
);
return;
}
console.info("[自动拾音] 检测到5秒静音暂停拾音等待ASR结果");
setPickPhase("processing", "RTC静音超时等待ASR结果");
humanInstanceRef.value?.muteMicrophone?.(true);
isMuted.value = true;
canTransfer.value = false;
},
() => {
// 声音恢复回调:仅用于日志记录,不自动改变状态
// 状态转换由 ASR 的 QUERY 结果驱动(有实质内容才转换)
// 避免环境噪音触发 idle → recording 导致误进入识别状态
console.info("[自动拾音] 检测到声音");
},
);
if (!started) {
console.warn("[自动拾音] 静音检测器启动失败,可能没有麦克风权限");
}
};
/**
*
*/
const resumeAutoPickup = () => {
humanInstanceRef.value?.muteMicrophone?.(false);
isMuted.value = false;
canTransfer.value = true;
const resumed = silenceDetector.resume();
if (!resumed) {
console.warn("[自动拾音] resume 失败,回退到 start 重新获取麦克风");
startSilenceDetection();
}
};
const startRecord = async () => {
if (isRecording.value) return;
// 播报中不允许录音,防止中断数字人播报
if (isBroadcasting.value) {
console.info("[状态机] 数字人播报中,不可开始录音");
return;
}
// waiting/broadcasting状态下不允许录音
if (pickPhase.value === "waiting" || pickPhase.value === "broadcasting") {
console.info(`[状态机] 当前状态 ${pickPhase.value},不可开始录音`);
return;
}
if (enableInterruptRef.value) {
await humanInstanceRef.value?.interrupt();
}
isRecording.value = true;
recordStartTime = Date.now();
setPickPhase("recording", "BTN模式开始录音");
humanInstanceRef.value?.startRecord?.();
};
const stopRecord = () => {
if (!isRecording.value) return;
isRecording.value = false;
if (pickPhase.value === "recording") {
setPickPhase("processing", "BTN模式松手等待ASR结果");
}
humanInstanceRef.value?.stopRecord?.();
};
// 开场白自动播放
watch(
[realTimeVideoReady, wsConnected, checkOver, videoIsMuted, audioMode],
([isVideoReady, isWsConnected, isCheckOver, isVideoMuted, mode]) => {
if (mode === "rtc") {
changeMicrophoneState(true);
}
if (openingPlayed.value) return;
if (isVideoReady && isWsConnected && isCheckOver && !isVideoMuted) {
openingPlayed.value = true;
const commandId = uuidv4();
if (mode === "rtc") {
humanInstanceRef.value?.muteMicrophone?.(true);
}
setTimeout(() => {
humanInstanceRef.value?.sendMessage?.(
{
action: "TEXT_RENDER",
body: DEFAULT_TEXT,
requestId: commandId,
},
({ action }: { action: string }) => {
if (action === "FINISHED") {
isBroadcasting.value = false;
if (mode === "rtc") {
humanInstanceRef.value?.muteMicrophone?.(false);
isMuted.value = false;
setPickPhase("idle", "开场白播放完成RTC模式就绪");
startSilenceDetection();
} else {
setPickPhase("idle", "开场白播放完成");
}
}
},
);
}, OPENING_TTS_DELAY_MS);
}
},
);
// 键盘事件
const onKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
if (e.repeat || audioMode.value !== "btn") return;
if (e.code === "Space" || e.keyCode === 32) {
startRecord();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.repeat) return;
if (audioMode.value === "btn" && (e.code === "Space" || e.keyCode === 32)) {
stopRecord();
}
};
watch(
audioMode,
(mode) => {
if (mode === "btn") {
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
return;
}
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
},
{ immediate: true },
);
onBeforeUnmount(() => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
silenceDetector.stop();
clearBroadcastTimeout();
tokenManager.stopAutoRefresh();
const inst = humanInstanceRef.value;
humanInstanceRef.value = null;
if (inst) {
void inst.destroy().catch((error: unknown) => {
console.error("组件卸载销毁数字人实例失败:", error);
});
}
});
return {
// 状态
humanInstanceRef,
isConnected,
realTimeVideoReady,
wsConnected,
videoIsMuted,
audioMode,
isRecording,
canTransfer,
isMuted,
token,
tokenManager,
// 拾音状态机
pickPhase,
setPickPhase,
// 播报状态
isBroadcasting,
// 字幕
subtitleMode,
currentAISubtitle,
currentUserSubtitle,
chatMessages,
// 方法
connect,
disconnect,
textRender,
streamTextRender,
interrupt,
muteHuman,
unMuteHuman,
playHuman,
pauseHuman,
changeMicrophoneState,
startRecord,
stopRecord,
};
}

115
src/hooks/useLLM.ts Normal file
View File

@ -0,0 +1,115 @@
import { ref } from "vue";
// 火山方舟 Chat Completions API 配置
const LLM_API_URL =
"https://ai.yantootech.com/polyhedron/api/ark/chat/completions";
const DEFAULT_MODEL = "bot-20260605135355-68pth";
interface LLMMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface LLMChoice {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}
interface LLMResponse {
id?: string;
object?: string;
created?: number;
model?: string;
choices?: LLMChoice[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
error?: string;
details?: string;
}
export function useLLM() {
const isLoading = ref(false);
const error = ref("");
const conversationHistory = ref<LLMMessage[]>([]);
const SYSTEM_PROMPT: LLMMessage = {
role: "system",
content: "你是一个友好的数字人助手,请用简洁自然的语言回答用户的问题。",
};
const sendToLLM = async (userMessage: string): Promise<string> => {
if (!userMessage.trim()) return "";
isLoading.value = true;
error.value = "";
conversationHistory.value.push({
role: "user",
content: userMessage,
});
try {
const messages: LLMMessage[] = [
SYSTEM_PROMPT,
...conversationHistory.value,
];
const response = await fetch(LLM_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: DEFAULT_MODEL,
stream: false,
messages,
}),
});
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
const data: LLMResponse = await response.json();
if (data.error) {
throw new Error(`API错误: ${data.error}`);
}
const aiReply = data.choices?.[0]?.message?.content || "";
conversationHistory.value.push({
role: "assistant",
content: aiReply,
});
return aiReply;
} catch (err: any) {
const errorMsg = err.message || "请求失败";
error.value = errorMsg;
console.error("LLM请求错误:", err);
throw err;
} finally {
isLoading.value = false;
}
};
const clearHistory = () => {
conversationHistory.value = [];
};
return {
isLoading,
error,
conversationHistory,
sendToLLM,
clearHistory,
};
}

70
src/hooks/useTTS.ts Normal file
View File

@ -0,0 +1,70 @@
import { ref } from "vue";
export function useTTS() {
const isSpeaking = ref(false);
let currentUtterance: SpeechSynthesisUtterance | null = null;
const speak = (text: string, onEnd?: () => void): void => {
if (!text.trim()) return;
// 先停止当前播放
stop();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// 尝试选择中文语音
const voices = window.speechSynthesis.getVoices();
const zhVoice = voices.find((v) => v.lang.includes("zh"));
if (zhVoice) {
utterance.voice = zhVoice;
}
utterance.onstart = () => {
isSpeaking.value = true;
};
utterance.onend = () => {
isSpeaking.value = false;
currentUtterance = null;
onEnd?.();
};
utterance.onerror = (e) => {
console.error("TTS播放错误:", e);
isSpeaking.value = false;
currentUtterance = null;
onEnd?.();
};
currentUtterance = utterance;
window.speechSynthesis.speak(utterance);
};
const stop = (): void => {
if (window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
isSpeaking.value = false;
currentUtterance = null;
};
const pause = (): void => {
window.speechSynthesis.pause();
};
const resume = (): void => {
window.speechSynthesis.resume();
};
return {
isSpeaking,
speak,
stop,
pause,
resume,
};
}

9
src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(ElementPlus);
app.mount("#app");

38
src/style.css Normal file
View File

@ -0,0 +1,38 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
color: #fff;
background-color: #0a0e1a;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
:root {
--primary-color: #4f6ef7;
--primary-light: #6c8cff;
--bg-dark: #0a0e1a;
--card-bg: rgba(255, 255, 255, 0.04);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: rgba(255, 255, 255, 0.92);
--text-secondary: rgba(255, 255, 255, 0.5);
--ai-accent: #34d399;
--user-accent: #6c8cff;
--danger-color: #f87171;
--purple-accent: #c084fc;
}

View File

@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CryptoJS from "crypto-js";
import {
generateToken,
isTokenExpiring,
isTokenExpired,
isAuthError,
TokenManager,
} from "../utils/tokenManager";
const TEST_APP_ID = "test-app-id";
const TEST_APP_KEY = "test-app-key";
// ===== Token 生成算法测试 =====
describe("generateToken", () => {
it("应生成格式为 appId/signature/time 的 token", () => {
const result = generateToken(TEST_APP_ID, TEST_APP_KEY);
expect(result.token).toMatch(
/^test-app-id\/[a-f0-9]+\/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
);
});
it("签名应使用 HMAC-SHA256(appId+time, appKey) 算法", () => {
const result = generateToken(TEST_APP_ID, TEST_APP_KEY);
const parts = result.token.split("/");
const signature = parts[1];
const time = parts.slice(2).join("/"); // 时间戳含 /,需特殊处理
const expectedSignature = CryptoJS.HmacSHA256(
TEST_APP_ID + time,
TEST_APP_KEY,
).toString(CryptoJS.enc.Hex);
expect(signature).toBe(expectedSignature);
});
it("过期时间应为当前时间往后60分钟", () => {
const before = Date.now();
const result = generateToken(TEST_APP_ID, TEST_APP_KEY);
const after = Date.now();
const expiresAtMs = new Date(result.expiresAt).getTime();
const expectedMin = before + 60 * 60 * 1000;
const expectedMax = after + 60 * 60 * 1000;
expect(expiresAtMs).toBeGreaterThanOrEqual(expectedMin);
expect(expiresAtMs).toBeLessThanOrEqual(expectedMax);
});
it("appId 为空时应抛出错误", () => {
expect(() => generateToken("", TEST_APP_KEY)).toThrow(
"appId 和 appKey 不能为空",
);
});
it("appKey 为空时应抛出错误", () => {
expect(() => generateToken(TEST_APP_ID, "")).toThrow(
"appId 和 appKey 不能为空",
);
});
it("不同 appKey 应生成不同签名", () => {
const result1 = generateToken(TEST_APP_ID, "key1");
const result2 = generateToken(TEST_APP_ID, "key2");
// 时间戳不同,签名也不同;但即使时间戳相同,不同 key 签名也不同
expect(result1.token).not.toBe(result2.token);
});
});
// ===== Token 过期检测测试 =====
describe("isTokenExpiring", () => {
it("剩余有效期超过30秒时应返回 false", () => {
const futureTime = new Date(Date.now() + 60_000).toISOString();
expect(isTokenExpiring(futureTime)).toBe(false);
});
it("剩余有效期不足30秒时应返回 true", () => {
const nearExpiry = new Date(Date.now() + 10_000).toISOString();
expect(isTokenExpiring(nearExpiry)).toBe(true);
});
it("已过期的 Token 应返回 true", () => {
const pastTime = new Date(Date.now() - 1000).toISOString();
expect(isTokenExpiring(pastTime)).toBe(true);
});
});
describe("isTokenExpired", () => {
it("未过期的 Token 应返回 false", () => {
const futureTime = new Date(Date.now() + 60_000).toISOString();
expect(isTokenExpired(futureTime)).toBe(false);
});
it("已过期的 Token 应返回 true", () => {
const pastTime = new Date(Date.now() - 1000).toISOString();
expect(isTokenExpired(pastTime)).toBe(true);
});
});
describe("isAuthError", () => {
it("401 应为授权错误", () => {
expect(isAuthError(401)).toBe(true);
});
it("403 应为授权错误", () => {
expect(isAuthError(403)).toBe(true);
});
it("200 不应为授权错误", () => {
expect(isAuthError(200)).toBe(false);
});
it("500 不应为授权错误", () => {
expect(isAuthError(500)).toBe(false);
});
});
// ===== TokenManager 测试 =====
describe("TokenManager", () => {
let manager: TokenManager;
beforeEach(() => {
manager = new TokenManager(TEST_APP_ID, TEST_APP_KEY);
});
afterEach(() => {
manager.stopAutoRefresh();
});
it("构造时应自动生成初始 Token", () => {
const token = manager.getToken();
expect(token).toMatch(
/^test-app-id\/[a-f0-9]+\/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
);
});
it("getTokenInfo 应返回有效的 TokenInfo", () => {
const info = manager.getTokenInfo();
expect(info.token).toBeTruthy();
expect(info.expiresAt).toBeTruthy();
expect(new Date(info.expiresAt).getTime()).toBeGreaterThan(Date.now());
});
it("appId 为空时构造应抛出错误", () => {
expect(() => new TokenManager("", TEST_APP_KEY)).toThrow(
"appId 和 appKey 不能为空",
);
});
it("appKey 为空时构造应抛出错误", () => {
expect(() => new TokenManager(TEST_APP_ID, "")).toThrow(
"appId 和 appKey 不能为空",
);
});
it("Token 更新回调应被正确调用", () => {
const callback = vi.fn();
manager.onTokenRefreshed(callback);
// 手动触发刷新:通过模拟 Token 即将过期
// 直接调用内部方法不方便,我们通过重新构造来验证回调
const manager2 = new TokenManager(TEST_APP_ID, TEST_APP_KEY);
const callback2 = vi.fn();
manager2.onTokenRefreshed(callback2);
// 验证回调已注册
expect(callback2).not.toHaveBeenCalled();
manager2.stopAutoRefresh();
});
describe("自动刷新", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("启动自动刷新后应设置定时器", () => {
manager.startAutoRefresh();
// 定时器已启动10秒后应检查一次
const initialToken = manager.getToken();
vi.advanceTimersByTime(10_000);
// Token 未过期,不应变化
expect(manager.getToken()).toBeTruthy();
});
it("停止自动刷新后定时器应被清除", () => {
manager.startAutoRefresh();
manager.stopAutoRefresh();
// 不应抛出异常
vi.advanceTimersByTime(60_000);
});
it("Token 即将过期时应自动刷新", () => {
// 在 fake timers 环境下重新构造 manager确保 token 过期时间与 fake 时钟对齐
const fakeManager = new TokenManager(TEST_APP_ID, TEST_APP_KEY);
const callback = vi.fn();
fakeManager.onTokenRefreshed(callback);
// 推进59分31秒使剩余有效期不足30秒
vi.advanceTimersByTime(59 * 60 * 1000 + 31 * 1000);
// 启动自动刷新定时器
fakeManager.startAutoRefresh();
// 触发定时器检查10秒间隔
vi.advanceTimersByTime(10_000);
// Token 应已被刷新
expect(callback).toHaveBeenCalled();
fakeManager.stopAutoRefresh();
});
});
describe("授权错误触发刷新", () => {
it("401 错误应触发刷新", async () => {
const callback = vi.fn();
manager.onTokenRefreshed(callback);
await manager.refreshOnAuthError(401);
expect(callback).toHaveBeenCalled();
});
it("403 错误应触发刷新", async () => {
const callback = vi.fn();
manager.onTokenRefreshed(callback);
await manager.refreshOnAuthError(403);
expect(callback).toHaveBeenCalled();
});
it("200 错误不应触发刷新", async () => {
const callback = vi.fn();
manager.onTokenRefreshed(callback);
await manager.refreshOnAuthError(200);
expect(callback).not.toHaveBeenCalled();
});
});
describe("重试机制", () => {
it("并发刷新请求应被合并(原子性)", async () => {
// 同时触发多次刷新
const promises = [
manager.refreshOnAuthError(401),
manager.refreshOnAuthError(401),
manager.refreshOnAuthError(401),
];
await Promise.all(promises);
// 应只刷新一次,不会出错
const token = manager.getToken();
expect(token).toBeTruthy();
});
});
});

15
src/types/index.ts Normal file
View File

@ -0,0 +1,15 @@
export interface ChatMessage {
id: string;
role: 'user' | 'ai';
content: string;
timestamp: number;
completed: boolean;
}
export type SubtitleMode = 'ai-only' | 'bubble' | 'merged';
export const SUBTITLE_MODE_LABELS: Record<SubtitleMode, string> = {
'ai-only': 'AI字幕',
'bubble': '对话气泡',
'merged': '合并气泡',
};

View File

@ -0,0 +1,240 @@
/**
*
* 使 Web Audio API AnalyserNode
*
*/
export class SilenceDetector {
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private mediaStream: MediaStream | null = null;
private animationFrameId: number | null = null;
private silenceStart: number = 0;
private isSilent = false;
private running = false;
// 音量阈值0-128低于此值视为静音
private volumeThreshold: number;
// 连续静音时长阈值(毫秒)
private silenceDurationMs: number;
// 静音回调
private onSilenceCallback: (() => void) | null = null;
// 声音恢复回调
private onSoundResumedCallback: (() => void) | null = null;
constructor(
options: {
volumeThreshold?: number;
silenceDurationMs?: number;
} = {},
) {
this.volumeThreshold = options.volumeThreshold ?? 8;
this.silenceDurationMs = options.silenceDurationMs ?? 5000;
}
/**
* AudioContext
*/
async start(
onSilence: () => void,
onSoundResumed?: () => void,
): Promise<boolean> {
// 如果已经在运行,先停止
if (this.running) {
this.pause();
}
this.onSilenceCallback = onSilence;
this.onSoundResumedCallback = onSoundResumed ?? null;
// 如果已有可用的音频资源,直接复用
if (this.audioContext && this.analyser && this.mediaStream) {
// 检查 AudioContext 是否已关闭
if (this.audioContext.state === "closed") {
this.audioContext = null;
this.analyser = null;
this.mediaStream = null;
} else {
// 复用已有资源,仅重启检测循环
this.resetState();
this.running = true;
this.detectLoop();
return true;
}
}
// 首次启动,获取麦克风权限并创建音频资源
try {
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
this.audioContext = new AudioContext();
const source = this.audioContext.createMediaStreamSource(
this.mediaStream,
);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.minDecibels = -90;
this.analyser.maxDecibels = -10;
source.connect(this.analyser);
this.resetState();
this.running = true;
this.detectLoop();
return true;
} catch (error) {
console.error("[SilenceDetector] 启动失败:", error);
return false;
}
}
/**
* AudioContext/Stream
* AI回复完成时自动恢复场景
*/
resume(): boolean {
if (!this.audioContext || !this.analyser || !this.mediaStream) {
console.warn(
"[SilenceDetector] resume 失败:无可用音频资源,请先调用 start()",
);
return false;
}
// 检查 AudioContext 状态
if (this.audioContext.state === "closed") {
console.warn("[SilenceDetector] resume 失败AudioContext 已关闭");
return false;
}
// 如果已在运行,先暂停
if (this.running) {
this.pause();
}
// 恢复可能被挂起的 AudioContext
if (this.audioContext.state === "suspended") {
this.audioContext.resume().catch(() => {});
}
this.resetState();
this.running = true;
this.detectLoop();
console.info("[SilenceDetector] 检测已恢复");
return true;
}
/**
*
*
*/
pause(): void {
this.running = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.isSilent = false;
this.silenceStart = 0;
}
/**
*
*
*/
stop(): void {
this.pause();
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.audioContext) {
this.audioContext.close().catch(() => {});
this.audioContext = null;
}
this.analyser = null;
this.onSilenceCallback = null;
this.onSoundResumedCallback = null;
}
/**
*
*/
resetSilenceTimer(): void {
this.isSilent = false;
this.silenceStart = 0;
}
/**
*
*/
private resetState(): void {
this.isSilent = false;
this.silenceStart = 0;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
/**
*
*/
private detectLoop = (): void => {
if (!this.running || !this.analyser) return;
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(dataArray);
// 计算平均音量
const average =
dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
const now = Date.now();
if (average < this.volumeThreshold) {
// 静音状态
if (!this.isSilent) {
this.isSilent = true;
this.silenceStart = now;
}
const silenceElapsed = now - this.silenceStart;
if (silenceElapsed >= this.silenceDurationMs) {
console.info(
`[SilenceDetector] 连续${this.silenceDurationMs / 1000}秒无声音,触发静音回调`,
);
// 先取消待执行的动画帧,防止残留回调
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// 暂停检测(设置 running=false再触发回调
// 这样外部恢复时调用 resume() 才能正确重启检测循环
this.running = false;
this.isSilent = false;
this.silenceStart = 0;
this.onSilenceCallback?.();
return;
}
} else {
// 有声音
if (this.isSilent) {
this.isSilent = false;
this.silenceStart = 0;
this.onSoundResumedCallback?.();
}
}
this.animationFrameId = requestAnimationFrame(this.detectLoop);
};
}

39
src/utils/audioUtils.ts Normal file
View File

@ -0,0 +1,39 @@
export const checkPlayUnMute: () => Promise<boolean> = () => {
return new Promise((resolve) => {
const audioElem: HTMLAudioElement = document.createElement("audio");
audioElem.src =
"data:audio/wav;base64,UklGRp4AAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAATElTVBoAAABJTkZPSVNGVA0AAABMYXZmNjEuNy4xMDAAAGRhdGFYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
audioElem.muted = false;
const playPromise: Promise<void> | undefined = audioElem.play();
if (playPromise !== undefined) {
playPromise
.then(() => resolve(true))
.catch((error: Error) => {
resolve(
error.name === "NotAllowedError" || error.name === "AbortError"
? false
: false,
);
});
setTimeout(() => {
audioElem.remove();
resolve(true);
}, 300);
} else {
audioElem.remove();
resolve(false);
}
});
};
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}

225
src/utils/tokenManager.ts Normal file
View File

@ -0,0 +1,225 @@
import CryptoJS from "crypto-js";
/** Token 有效期毫秒60分钟 */
const TOKEN_TTL_MS = 60 * 60 * 1000;
/** Token 过期前提前刷新时间毫秒30秒 */
const TOKEN_REFRESH_BEFORE_EXPIRY_MS = 30 * 1000;
/** Token 更新失败最大重试次数 */
const MAX_RETRY_COUNT = 3;
/** 重试间隔(毫秒) */
const RETRY_INTERVAL_MS = 1000;
/** Token 信息结构 */
export interface TokenInfo {
/** Authorization 请求头值,格式: appId/signature/time */
token: string;
/** Token 过期时间ISO格式 */
expiresAt: string;
}
/**
* Token
* - 使 HMAC-SHA256 appKey appId +
* - TokenInfo token
*/
export function generateToken(appId: string, appKey: string): TokenInfo {
if (!appId || !appKey) {
throw new Error("[Token] appId 和 appKey 不能为空");
}
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
const message = appId + expiresAt;
const signature = CryptoJS.HmacSHA256(message, appKey).toString(
CryptoJS.enc.Hex,
);
const token = `${appId}/${signature}/${expiresAt}`;
console.info(`[Token] 生成成功,过期时间: ${expiresAt}`);
return { token, expiresAt };
}
/**
* Token 30
*/
export function isTokenExpiring(expiresAt: string): boolean {
const expiryTime = new Date(expiresAt).getTime();
const remaining = expiryTime - Date.now();
return remaining <= TOKEN_REFRESH_BEFORE_EXPIRY_MS;
}
/**
* Token
*/
export function isTokenExpired(expiresAt: string): boolean {
return new Date(expiresAt).getTime() <= Date.now();
}
/**
* HTTP
*/
export function isAuthError(status: number): boolean {
return status === 401 || status === 403;
}
/**
* Token Token
*
* 使
* const manager = new TokenManager(appId, appKey);
* const tokenInfo = manager.getTokenInfo(); // 获取当前 Token
* manager.startAutoRefresh(); // 启动自动刷新
* manager.stopAutoRefresh(); // 停止自动刷新
* manager.refreshOnAuthError(401); // 授权错误时触发刷新
*/
export class TokenManager {
private appId: string;
private appKey: string;
private tokenInfo: TokenInfo | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private isRefreshing = false; // 原子锁,防止并发刷新
private retryCount = 0;
/** Token 更新回调列表 */
private onTokenRefreshedCallbacks: Array<(tokenInfo: TokenInfo) => void> = [];
constructor(appId: string, appKey: string) {
if (!appId || !appKey) {
throw new Error("[TokenManager] appId 和 appKey 不能为空");
}
this.appId = appId;
this.appKey = appKey;
// 初始化时立即生成一个 Token
this.tokenInfo = generateToken(this.appId, this.appKey);
}
/**
* TokenInfo
*/
getTokenInfo(): TokenInfo {
if (this.tokenInfo && !isTokenExpiring(this.tokenInfo.expiresAt)) {
return this.tokenInfo;
}
// 即将过期或已过期,同步刷新
this.refreshSync();
return this.tokenInfo!;
}
/**
* token
*/
getToken(): string {
return this.getTokenInfo().token;
}
/**
* Token
*/
onTokenRefreshed(callback: (tokenInfo: TokenInfo) => void): void {
this.onTokenRefreshedCallbacks.push(callback);
}
/**
*
* - 10 Token
* - 30
*/
startAutoRefresh(): void {
this.stopAutoRefresh();
console.info("[TokenManager] 启动自动刷新定时器");
this.refreshTimer = setInterval(() => {
if (this.tokenInfo && isTokenExpiring(this.tokenInfo.expiresAt)) {
console.info("[TokenManager] Token 即将过期,触发自动刷新");
this.refreshWithRetry();
}
}, 10_000);
}
/**
*
*/
stopAutoRefresh(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
console.info("[TokenManager] 停止自动刷新定时器");
}
}
/**
* Token
* - 401/403
*/
async refreshOnAuthError(status: number): Promise<void> {
if (!isAuthError(status)) return;
console.info(`[TokenManager] 检测到授权错误 ${status},触发 Token 刷新`);
await this.refreshWithRetry();
}
/**
* Token使
*/
private refreshSync(): void {
try {
this.tokenInfo = generateToken(this.appId, this.appKey);
this.retryCount = 0;
} catch (error) {
console.error("[TokenManager] Token 生成失败:", error);
throw error;
}
}
/**
*
* - MAX_RETRY_COUNT RETRY_INTERVAL_MS
* - 使
*/
private async refreshWithRetry(): Promise<void> {
// 原子锁:防止并发刷新
if (this.isRefreshing) {
console.info("[TokenManager] Token 刷新进行中,跳过本次请求");
return;
}
this.isRefreshing = true;
try {
while (this.retryCount < MAX_RETRY_COUNT) {
try {
this.tokenInfo = generateToken(this.appId, this.appKey);
this.retryCount = 0;
console.info("[TokenManager] Token 刷新成功");
// 通知所有回调
this.onTokenRefreshedCallbacks.forEach((cb) => {
try {
cb(this.tokenInfo!);
} catch (e) {
console.error("[TokenManager] Token 更新回调执行失败:", e);
}
});
return;
} catch (error) {
this.retryCount++;
console.warn(
`[TokenManager] Token 刷新失败(第 ${this.retryCount}/${MAX_RETRY_COUNT} 次):`,
error,
);
if (this.retryCount < MAX_RETRY_COUNT) {
await new Promise((resolve) =>
setTimeout(resolve, RETRY_INTERVAL_MS),
);
}
}
}
console.error(
`[TokenManager] Token 刷新失败,已达到最大重试次数 ${MAX_RETRY_COUNT}`,
);
} finally {
this.isRefreshing = false;
}
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

31
vite.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import legacy from "@vitejs/plugin-legacy";
import path from "path";
export default defineConfig({
base: "./",
plugins: [
vue(),
legacy({
targets: [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead",
"iOS >= 12",
"Android >= 5",
],
additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
modernPolyfills: true,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
globals: true,
},
});