项目初始化
This commit is contained in:
commit
db73bcd2ae
12
index.html
Normal file
12
index.html
Normal 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
5920
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
1298
src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
907
src/components/SubtitlePanel.vue
Normal file
907
src/components/SubtitlePanel.vue
Normal 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;
|
||||
}
|
||||
|
||||
/* ===== 模式1:AI字幕 ===== */
|
||||
.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>
|
||||
833
src/hooks/useDigitalHuman.ts
Normal file
833
src/hooks/useDigitalHuman.ts
Normal 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
115
src/hooks/useLLM.ts
Normal 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
70
src/hooks/useTTS.ts
Normal 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
9
src/main.ts
Normal 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
38
src/style.css
Normal 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;
|
||||
}
|
||||
269
src/tests/tokenManager.test.ts
Normal file
269
src/tests/tokenManager.test.ts
Normal 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
15
src/types/index.ts
Normal 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': '合并气泡',
|
||||
};
|
||||
240
src/utils/SilenceDetector.ts
Normal file
240
src/utils/SilenceDetector.ts
Normal 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
39
src/utils/audioUtils.ts
Normal 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
225
src/utils/tokenManager.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
31
vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user