Compare commits

...

6 Commits

Author SHA1 Message Date
libingxiang
d21d494942 播报中说话会停掉声音,而且不拾音bug修复
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m58s
2026-06-25 15:45:27 +08:00
libingxiang
44ffbf8067 合并气泡字幕超出范围
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m54s
2026-06-24 11:25:54 +08:00
libingxiang
c3ec7b63c3 折叠和展开字幕去掉
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m53s
2026-06-24 09:16:14 +08:00
libingxiang
1a9cd283a1 播放中打断
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m56s
2026-06-23 14:54:04 +08:00
libingxiang
60c8723232 替换appid和appkey
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 2m2s
2026-06-23 14:32:29 +08:00
xupan
9bcf1d95bd feat:字体样式调整
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m53s
2026-06-16 15:56:13 +08:00
3 changed files with 226 additions and 200 deletions

View File

@ -752,7 +752,7 @@ async function handleVoiceQueryComplete(text: string) {
} }
.subtitle-container { .subtitle-container {
max-height: 30vh; max-height: 40vh;
background: rgba(10, 14, 30, 0.1); background: rgba(10, 14, 30, 0.1);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
@ -1222,6 +1222,9 @@ async function handleVoiceQueryComplete(text: string) {
/* 平板/大屏 (769px+) */ /* 平板/大屏 (769px+) */
@media (min-width: 769px) { @media (min-width: 769px) {
.subtitle-overlay {
width: 80%;
}
} }
/* 横屏模式 */ /* 横屏模式 */

View File

@ -135,39 +135,12 @@
<div <div
class="bubble-text" class="bubble-text"
:class="{ :class="{
collapsed: isCollapsed(msg.id) && isLongText(msg.content),
'text-user': msg.role === 'user', 'text-user': msg.role === 'user',
'text-ai': msg.role === 'ai', 'text-ai': msg.role === 'ai',
}" }"
:ref="(el) => setMsgRef(msg.id, el)"
> >
{{ msg.content }} {{ msg.content }}
</div> </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>
</div> </div>
@ -202,76 +175,14 @@
class="merged-bubble" class="merged-bubble"
> >
<div v-if="pair.user" class="merged-user-section"> <div v-if="pair.user" class="merged-user-section">
<div <div class="merged-text text-user">
class="merged-text text-user"
:class="{
collapsed:
isCollapsed(pair.user.id) && isLongText(pair.user.content),
}"
>
{{ pair.user.content }} {{ pair.user.content }}
</div> </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>
<div v-if="pair.ai" class="merged-ai-section"> <div v-if="pair.ai" class="merged-ai-section">
<div <div class="merged-text text-ai">
class="merged-text text-ai"
:class="{
collapsed:
isCollapsed(pair.ai.id) && isLongText(pair.ai.content),
}"
>
{{ pair.ai.content }} {{ pair.ai.content }}
</div> </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> </div>
@ -316,20 +227,59 @@ const aiSubtitleTextRef = ref<HTMLElement | null>(null);
const aiFontSize = ref(18); const aiFontSize = ref(18);
const aiTextOverflow = ref(false); const aiTextOverflow = ref(false);
// AI // 1440×2560
const isTargetResolution = ref(false);
/**
* 检测当前视口是否为 1440×2560 分辨率
* 使用 window.innerWidth innerHeight 精确匹配
*/
const checkResolution = () => {
const TARGET_WIDTH = 1440;
const TARGET_HEIGHT = 2560;
console.log("------------", window.innerWidth, window.innerHeight);
isTargetResolution.value =
(window.innerWidth === TARGET_WIDTH &&
window.innerHeight === TARGET_HEIGHT) ||
(window.innerWidth > 1000 && window.innerHeight > 1680);
};
/**
* 获取目标分辨率下的字号基准值
* 1440×2560 下使用更大的字号以确保清晰可读
*/
const getTargetFontSizeBase = (textLength: number): number => {
if (textLength <= 50) return 36;
if (textLength <= 100) return 32;
if (textLength <= 200) return 28;
if (textLength <= 400) return 24;
return 22;
};
/**
* 获取普通分辨率下的字号基准值
*/
const getNormalFontSizeBase = (textLength: number): number => {
if (textLength <= 50) return 18;
if (textLength <= 100) return 16;
if (textLength <= 200) return 14;
if (textLength <= 400) return 13;
return 12;
};
// AI
const computeAIFontSize = () => { const computeAIFontSize = () => {
const text = props.aiSubtitle; const text = props.aiSubtitle;
console.log("------------", text);
if (!text) { if (!text) {
aiFontSize.value = 18; aiFontSize.value = isTargetResolution.value ? 36 : 18;
return; return;
} }
const len = text.length; const len = text.length;
if (len <= 50) aiFontSize.value = 18; aiFontSize.value = isTargetResolution.value
else if (len <= 100) aiFontSize.value = 16; ? getTargetFontSizeBase(len)
else if (len <= 200) aiFontSize.value = 14; : getNormalFontSizeBase(len);
else if (len <= 400) aiFontSize.value = 13;
else aiFontSize.value = 12;
}; };
// AI // AI
@ -352,29 +302,6 @@ const scrollAIToBottom = () => {
} }
}; };
// ===== / =====
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 = [ const modeOptions = [
{ value: "ai-only" as SubtitleMode, label: "AI字幕", icon: "💬" }, { value: "ai-only" as SubtitleMode, label: "AI字幕", icon: "💬" },
@ -400,12 +327,30 @@ const handleClickOutside = (e: MouseEvent) => {
} }
}; };
/**
* 处理视口尺寸变化重新检测分辨率并更新字号
*/
const handleResize = () => {
const wasTarget = isTargetResolution.value;
checkResolution();
//
if (wasTarget !== isTargetResolution.value) {
computeAIFontSize();
}
};
onMounted(() => { onMounted(() => {
document.addEventListener("click", handleClickOutside); document.addEventListener("click", handleClickOutside);
//
checkResolution();
computeAIFontSize();
//
window.addEventListener("resize", handleResize);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", handleClickOutside); document.removeEventListener("click", handleClickOutside);
window.removeEventListener("resize", handleResize);
}); });
// ===== ===== // ===== =====
@ -475,21 +420,10 @@ watch(
}, },
); );
// + //
watch( watch(
() => props.messages.length, () => 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(() => { nextTick(() => {
scrollToBottom(); scrollToBottom();
}); });
@ -790,6 +724,7 @@ const scrollToBottom = () => {
.bubble-text { .bubble-text {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
letter-spacing: 0.2px;
word-break: break-word; word-break: break-word;
overflow: hidden; overflow: hidden;
transition: max-height 0.35s ease; transition: max-height 0.35s ease;
@ -803,45 +738,6 @@ const scrollToBottom = () => {
color: #ffffff; color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); 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合并气泡 ===== */ /* ===== 模式3合并气泡 ===== */
@ -874,6 +770,7 @@ const scrollToBottom = () => {
.merged-text { .merged-text {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
letter-spacing: 0.2px;
word-break: break-word; word-break: break-word;
overflow: hidden; overflow: hidden;
transition: max-height 0.35s ease; transition: max-height 0.35s ease;
@ -887,21 +784,113 @@ const scrollToBottom = () => {
color: #ffffff; color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); 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;
} }
/* ===== 1440×2560 分辨率专用样式 ===== */
@media (min-width: 1000px) and (min-height: 1680px) {
/* 一级正文:气泡/合并文本/占位符 */
.bubble-text,
.merged-text,
.subtitle-placeholder {
font-size: 24px;
line-height: 1.75;
letter-spacing: 0.5px;
font-weight: 400;
}
/* AI字幕字号由 JavaScript 动态控制,此处仅设置辅助样式 */
.ai-subtitle-text {
line-height: 1.75;
letter-spacing: 0.6px;
font-weight: 400;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
/* 二级UI下拉框/菜单项 */
.dropdown-trigger,
.dropdown-item {
font-size: 20px;
font-weight: 500;
}
/* 字幕容器:背景与边框适配大字号 */
.ai-subtitle-bar {
padding: 18px 24px;
border-radius: 12px;
background: linear-gradient(
135deg,
rgba(108, 140, 255, 0.12) 0%,
rgba(79, 110, 247, 0.06) 100%
);
border: 1px solid rgba(108, 140, 255, 0.15);
}
/* 气泡容器:间距与圆角适配 */
.bubble-body {
padding: 16px 20px;
border-radius: 20px;
}
.chat-bubble.bubble-user .bubble-body {
border-radius: 20px 20px 6px 20px;
margin-left: 36px;
}
.chat-bubble.bubble-ai .bubble-body {
border-radius: 20px 20px 20px 6px;
margin-right: 36px;
}
.merged-bubble {
border-radius: 18px;
}
.merged-user-section,
.merged-ai-section {
padding: 16px 20px;
}
/* 头部区域:间距放大 */
.subtitle-header {
padding-bottom: 14px;
margin-bottom: 14px;
}
.dropdown-trigger {
padding: 14px 22px;
border-radius: 14px;
}
.dropdown-item {
padding: 16px 22px;
border-radius: 12px;
}
/* 下拉菜单宽度适配 */
.dropdown-menu {
min-width: 240px;
padding: 8px;
border-radius: 16px;
}
/* 滚动指示器:尺寸适配 */
.scroll-hint {
width: 40px;
height: 40px;
margin-top: 12px;
}
/* 聊天列表间距 */
.chat-list {
gap: 12px;
}
.merged-list {
gap: 12px;
}
.bubble-text {
font-size: 28px !important;
} }
} }
</style> </style>

View File

@ -14,8 +14,10 @@ const SILENCE_TIMEOUT_MS = 5000;
const MIN_VALID_DURATION_SEC = 0.5; const MIN_VALID_DURATION_SEC = 0.5;
// 播报超时保护毫秒如果FINISHED回调未触发自动恢复状态 // 播报超时保护毫秒如果FINISHED回调未触发自动恢复状态
const BROADCAST_TIMEOUT_MS = 60000; const BROADCAST_TIMEOUT_MS = 60000;
const APPID = "i-sfkmd7h8ex6nd"; const APPID = "i-sfyjfm4jw2q2g";
const APP_KEY = "hs6b9nu9b080ap9hswgp"; const APP_KEY = "f6mgtnb0abt6ph3rihwm";
// 打断词列表:识别到这些词时自动打断播报
const INTERRUPT_KEYWORDS = ["稍等一下", "停一下", "抱歉打断一下", "不好意思", "对不起", "停", "闭嘴"];
/** /**
* *
@ -29,6 +31,17 @@ const hasSubstantialContent = (text: string): boolean => {
return cleaned.length > 0; return cleaned.length > 0;
}; };
/**
*
*/
const containsInterruptKeyword = (text: string): boolean => {
const cleaned = text.replace(
/[\s\u3000.,!?;:,。!?;:、…—\-_·""''()\[\]【】《》<>]/g,
"",
);
return INTERRUPT_KEYWORDS.some((keyword) => cleaned.includes(keyword));
};
enum ReadyState { enum ReadyState {
UNINSTANTIATED = -1, UNINSTANTIATED = -1,
CONNECTING = 0, CONNECTING = 0,
@ -141,6 +154,8 @@ export function useDigitalHuman(options?: {
// 播报超时保护计时器 // 播报超时保护计时器
let broadcastTimeoutId: ReturnType<typeof setTimeout> | null = null; let broadcastTimeoutId: ReturnType<typeof setTimeout> | null = null;
// 打断词检测时间戳用于忽略打断后延迟到达的ASR结果
let lastInterruptTime = 0;
const clearBroadcastTimeout = () => { const clearBroadcastTimeout = () => {
if (broadcastTimeoutId !== null) { if (broadcastTimeoutId !== null) {
@ -164,19 +179,23 @@ export function useDigitalHuman(options?: {
}; };
/** /**
* + *
* RTC模式下保持麦克风开启ASR识别打断词
*/ */
const enterBroadcasting = () => { const enterBroadcasting = () => {
isBroadcasting.value = true; isBroadcasting.value = true;
setPickPhase("broadcasting", "数字人开始播报"); setPickPhase("broadcasting", "数字人开始播报");
startBroadcastTimeout(); startBroadcastTimeout();
// RTC模式播报期间禁用拾音 // RTC模式播报期间保持麦克风开启允许ASR识别打断词
if (audioMode.value === "rtc") { if (audioMode.value === "rtc") {
humanInstanceRef.value?.muteMicrophone?.(true); humanInstanceRef.value?.muteMicrophone?.(false);
isMuted.value = true; isMuted.value = false;
canTransfer.value = false; canTransfer.value = true;
silenceDetector.pause(); const resumed = silenceDetector.resume();
console.info("[播报] RTC模式麦克风已静音静音检测器已暂停"); if (!resumed) {
startSilenceDetection();
}
console.info("[播报] RTC模式麦克风保持开启允许ASR识别打断词");
} }
console.info("[播报] 开始播报等待FINISHED信号"); console.info("[播报] 开始播报等待FINISHED信号");
}; };
@ -306,9 +325,15 @@ export function useDigitalHuman(options?: {
type, type,
} = JSON.parse(content.body); } = JSON.parse(content.body);
if (type === "QUERY") { if (type === "QUERY") {
// 播报期间忽略ASR结果双重守卫麦克风已静音但防止残余回调 // 播报期间检测打断词识别到打断词后仅中断播报并恢复拾音不调用LLM接口
if (pickPhase.value === "broadcasting") { if (pickPhase.value === "broadcasting") {
console.info("[状态机] 播报中收到ASR结果忽略"); const hasInterrupt = containsInterruptKeyword(subtitleContent);
if (hasInterrupt) {
console.info(`[打断词检测] 播报中识别到打断词: "${subtitleContent}"自动打断仅中断播报不调用LLM`);
interrupt();
} else {
console.info(`[打断词检测] 播报中收到ASR结果: "${subtitleContent}",非打断词,忽略`);
}
break; break;
} }
@ -321,6 +346,13 @@ export function useDigitalHuman(options?: {
// idle 状态下收到 QUERY必须验证内容有实质才允许状态转换 // idle 状态下收到 QUERY必须验证内容有实质才允许状态转换
// 环境噪音可能触发 ASR 产生空白/标点内容的 QUERY不应进入识别 // 环境噪音可能触发 ASR 产生空白/标点内容的 QUERY不应进入识别
if (pickPhase.value === "idle") { if (pickPhase.value === "idle") {
// 忽略打断后3秒内到达的延迟ASR结果避免打断词被当作新输入
if (Date.now() - lastInterruptTime < 3000) {
console.info(
"[状态机] 打断后延迟到达的ASR结果忽略",
);
break;
}
const hasSubstance = hasSubstantialContent(subtitleContent); const hasSubstance = hasSubstantialContent(subtitleContent);
if (!hasSubstance) { if (!hasSubstance) {
console.info( console.info(
@ -497,7 +529,7 @@ export function useDigitalHuman(options?: {
wrapperId: "human-wrapper", wrapperId: "human-wrapper",
connectParams: { connectParams: {
ttsPer: "CAP_4189", ttsPer: "CAP_4189",
figureId: "3999826", figureId: "4000748",
resolutionHeight: 1920, resolutionHeight: 1920,
resolutionWidth: 1080, resolutionWidth: 1080,
inactiveDisconnectSec: 300, inactiveDisconnectSec: 300,
@ -514,7 +546,7 @@ export function useDigitalHuman(options?: {
config.connectParams.pullAudioFromRtc = mode === "rtc"; config.connectParams.pullAudioFromRtc = mode === "rtc";
config.connectParams.pickAudioMode = config.connectParams.pickAudioMode =
mode === "btn" ? "pressButton" : "free"; mode === "btn" ? "pressButton" : "free";
config.connectParams.enableInterrupt = enableInterruptRef.value; config.connectParams.enableInterrupt = false;
} }
humanInstanceRef.value = new RealTimeHuman(config); humanInstanceRef.value = new RealTimeHuman(config);
@ -590,6 +622,8 @@ export function useDigitalHuman(options?: {
console.info("发送打断"); console.info("发送打断");
clearBroadcastTimeout(); clearBroadcastTimeout();
await humanInstanceRef.value?.interrupt?.(); await humanInstanceRef.value?.interrupt?.();
// 记录打断时间戳用于忽略打断后延迟到达的ASR结果
lastInterruptTime = Date.now();
// 打断后播报结束,恢复状态 // 打断后播报结束,恢复状态
if (isBroadcasting.value) { if (isBroadcasting.value) {
isBroadcasting.value = false; isBroadcasting.value = false;