项目初始化
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