Files
ai-video-fullstack/scripts/copilot-element2-two-party.js
Xin Wang fe443932f0 Refactor AI Element2 Gated Copilot script for improved audio handling
- Update the description to clarify that participant audio is fed into AI after both sides are connected.
- Introduce new functions to manage audio track patterns and enhance the logic for determining participant and operator presence.
- Improve gate status messaging to reflect the online status of participants and operators.
- Add volume handling functions for better audio visualization and feedback.
- Refactor existing functions to streamline audio processing and improve overall code clarity.
2026-07-09 08:54:15 +08:00

1330 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 12345 AI Element2 Gated Copilot
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Two-party gated bridge: only feed participant audio into AI after both sides are connected.
// @author You
// @match *://yhy-yw.22sj.cn:*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const DEFAULT_SIGNALING_URL = 'ws://182.92.86.220:8000/ws/voice';
const STATIC_AUDIO_ELEMENT_REASON = 'element-2:audio';
const STATIC_VIDEO_ELEMENT_REASON = 'element-0:video';
const PARTICIPANT_AUDIO_TRACK_PATTERN = /-audio-3-a$/;
const originalAddEventListener = RTCPeerConnection.prototype.addEventListener;
const originalAddTrack = RTCPeerConnection.prototype.addTrack;
const originalAddTransceiver = RTCPeerConnection.prototype.addTransceiver;
const originalSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
const remoteAudio = new Map();
const remoteVideo = new Map();
const businessSenders = new Map();
const aiOutputTrackIds = new Set();
const inboundMemory = new Map();
const outboundMemory = new Map();
const internalPcs = new WeakSet();
const AUDIO_UPLINK_STORAGE_KEY = 'element2-copilot-send-audio';
const VIDEO_UPLINK_STORAGE_KEY = 'element2-copilot-send-video';
let selectedRemoteAudio = null;
let selectedRemoteVideo = null;
let selectedBusinessSender = null;
let originalBusinessTrack = null;
let aiAudioTrack = null;
let aiPc = null;
let aiWs = null;
let aiDc = null;
let aiAudioSender = null;
let aiVideoSender = null;
let isAiTakeover = false;
let aiAttached = false;
let aiOutputAudioContext = null;
let aiOutputAnalyser = null;
let aiOutputAnalyserData = null;
let aiOutputAnalyserSource = null;
let aiOutputAnalyserTrack = null;
let inputVu = 0;
let outputVu = 0;
let inputHealthTimer = null;
let outputHealthTimer = null;
let assistantLine = null;
let assistantBuffer = '';
let inputRecorder = null;
let inputRecordingChunks = [];
let inputRecordingTrack = null;
let inputRecordingStartedAt = 0;
let videoRecorder = null;
let videoRecordingChunks = [];
let videoRecordingTrack = null;
let videoRecordingStartedAt = 0;
function log(message) {
console.log(`[AI Element2 Gated] ${message}`);
}
function readBoolSetting(key, fallback) {
try {
const value = localStorage.getItem(key);
if (value === null) return fallback;
return value === 'true';
} catch {
return fallback;
}
}
function writeBoolSetting(key, value) {
try {
localStorage.setItem(key, value ? 'true' : 'false');
} catch {
// Ignore storage failures; the current checkbox state still applies.
}
}
function shouldSendAudioToAi() {
return readBoolSetting(AUDIO_UPLINK_STORAGE_KEY, true);
}
function shouldSendVideoToAi() {
return readBoolSetting(VIDEO_UPLINK_STORAGE_KEY, true);
}
function appendChatLine(role, text, important = false) {
const box = document.getElementById('element2-copilot-log');
if (!box) return;
if (box.children.length === 1 && box.children[0].dataset.placeholder) box.innerHTML = '';
const row = document.createElement('div');
row.textContent = `[${new Date().toLocaleTimeString([], { hour12: false })}] ${role}: ${text}`;
row.style.marginBottom = '4px';
if (important) row.style.color = '#fcd34d';
box.appendChild(row);
while (box.children.length > 120) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
return row;
}
function startAssistantLine() {
assistantBuffer = '';
assistantLine = null;
}
function appendAssistantDelta(delta) {
if (!delta) return;
const box = document.getElementById('element2-copilot-log');
if (!box) {
console.log(`[AI Element2 Gated] AI: ${delta}`);
return;
}
if (box.children.length === 1 && box.children[0].dataset.placeholder) box.innerHTML = '';
if (!assistantLine) assistantLine = appendChatLine('AI', '');
assistantBuffer += delta;
assistantLine.textContent = `[${new Date().toLocaleTimeString([], { hour12: false })}] AI: ${assistantBuffer}`;
box.scrollTop = box.scrollHeight;
}
function finishAssistantLine() {
assistantLine = null;
assistantBuffer = '';
}
function logUserTranscript(text) {
if (!text?.trim()) return;
finishAssistantLine();
appendChatLine('用户', text.trim(), true);
}
function recorderMimeType() {
if (window.MediaRecorder?.isTypeSupported?.('audio/webm;codecs=opus')) return 'audio/webm;codecs=opus';
if (window.MediaRecorder?.isTypeSupported?.('audio/webm')) return 'audio/webm';
return '';
}
function recordingFileName(track) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const trackPart = track?.id ? track.id.slice(0, 8) : 'no-track';
return `ai-input-${trackPart}-${stamp}.webm`;
}
function videoRecordingFileName(track) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const trackPart = track?.id ? track.id.slice(0, 8) : 'no-track';
return `ai-video-${trackPart}-${stamp}.webm`;
}
function videoRecorderMimeType() {
if (window.MediaRecorder?.isTypeSupported?.('video/webm;codecs=vp8')) return 'video/webm;codecs=vp8';
if (window.MediaRecorder?.isTypeSupported?.('video/webm')) return 'video/webm';
return '';
}
async function startInputRecording() {
if (inputRecorder?.state === 'recording') return;
selectRemoteAudio('record');
if (!isLive(selectedRemoteAudio)) {
appendChatLine('录音', `没有可录制的 ${STATIC_AUDIO_ELEMENT_REASON} 音轨`, true);
return;
}
if (!window.MediaRecorder) {
appendChatLine('录音', '当前浏览器不支持 MediaRecorder', true);
return;
}
inputRecordingChunks = [];
inputRecordingTrack = selectedRemoteAudio;
inputRecordingStartedAt = Date.now();
const stream = new MediaStream([selectedRemoteAudio]);
const mimeType = recorderMimeType();
inputRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
inputRecorder.ondataavailable = (event) => {
if (event.data?.size > 0) inputRecordingChunks.push(event.data);
};
inputRecorder.onstop = () => {
const blob = new Blob(inputRecordingChunks, { type: inputRecorder?.mimeType || 'audio/webm' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = recordingFileName(inputRecordingTrack);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
const seconds = ((Date.now() - inputRecordingStartedAt) / 1000).toFixed(1);
appendChatLine('录音', `已保存 ${link.download}${seconds}s${(blob.size / 1024).toFixed(1)} KB`, true);
inputRecorder = null;
inputRecordingChunks = [];
inputRecordingTrack = null;
updateRecordButton();
};
inputRecorder.start(250);
appendChatLine('录音', `开始录制 AI 输入:${trackLabel(inputRecordingTrack)}`, true);
updateRecordButton();
}
function stopInputRecording() {
if (inputRecorder?.state === 'recording') inputRecorder.stop();
}
function toggleInputRecording() {
if (inputRecorder?.state === 'recording') stopInputRecording();
else startInputRecording();
}
function updateRecordButton() {
const button = document.getElementById('element2-copilot-record');
if (!button) return;
const recording = inputRecorder?.state === 'recording';
button.textContent = recording ? '停止录制 AI 输入' : '录制 AI 输入';
button.style.background = recording ? '#dc2626' : '#2563eb';
}
function updateVideoRecordButton() {
const button = document.getElementById('element2-copilot-record-video');
if (!button) return;
const recording = videoRecorder?.state === 'recording';
button.textContent = recording ? '停止录制 AI 视频' : '录制 AI 视频';
button.style.background = recording ? '#dc2626' : '#475569';
}
async function startVideoRecording() {
if (videoRecorder?.state === 'recording') return;
selectRemoteVideo('record-video');
if (!isLive(selectedRemoteVideo)) {
appendChatLine('录制视频', `没有可录制的 ${STATIC_VIDEO_ELEMENT_REASON} 视频轨`, true);
return;
}
if (!window.MediaRecorder) {
appendChatLine('录制视频', '当前浏览器不支持 MediaRecorder', true);
return;
}
videoRecordingChunks = [];
videoRecordingTrack = selectedRemoteVideo;
videoRecordingStartedAt = Date.now();
const stream = new MediaStream([videoRecordingTrack]);
const mimeType = videoRecorderMimeType();
videoRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
videoRecorder.ondataavailable = (event) => {
if (event.data?.size > 0) videoRecordingChunks.push(event.data);
};
videoRecorder.onstop = () => {
const blob = new Blob(videoRecordingChunks, { type: videoRecorder?.mimeType || 'video/webm' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = videoRecordingFileName(videoRecordingTrack);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
const seconds = ((Date.now() - videoRecordingStartedAt) / 1000).toFixed(1);
appendChatLine('录制视频', `已保存 ${link.download}${seconds}s${(blob.size / 1024).toFixed(1)} KB`, true);
videoRecorder = null;
videoRecordingChunks = [];
videoRecordingTrack = null;
updateVideoRecordButton();
};
videoRecorder.start(250);
appendChatLine('录制视频', `开始录制 AI 视频:${trackLabel(videoRecordingTrack)}`, true);
updateVideoRecordButton();
}
function stopVideoRecording() {
if (videoRecorder?.state === 'recording') videoRecorder.stop();
}
function toggleVideoRecording() {
if (videoRecorder?.state === 'recording') stopVideoRecording();
else startVideoRecording();
}
function pcId() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return 'PC-' + Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
}
function isInternalPc(pc) {
return Boolean(pc && (pc.__aiElement2GatedInternal || internalPcs.has(pc) || pc === aiPc));
}
function isLive(track) {
return track && track.readyState === 'live';
}
function trackLabel(track) {
if (!track) return 'none';
return `${track.id}${track.muted ? ' muted' : ''}`;
}
function markInternalPc(pc) {
if (!pc) return;
pc.__aiElement2GatedInternal = true;
internalPcs.add(pc);
}
function elementReasonFor(element) {
if (!element) return null;
const tag = element.tagName?.toLowerCase();
if (tag !== 'audio' && tag !== 'video') return null;
const elements = Array.from(document.querySelectorAll(tag));
const index = elements.indexOf(element);
if (index < 0) return `${tag}-srcObject`;
return `element-${index}:${tag}`;
}
function hasElementReason(item, reason) {
return Boolean(item?.reasons?.has(reason));
}
function elementAudioReasons(item) {
return Array.from(item?.reasons || []).filter((reason) => /^element-\d+:audio$/.test(reason));
}
function audioSourceLabel(item) {
const reasons = elementAudioReasons(item);
return reasons[0] || 'audio-track';
}
function updateSelectedAudioLabel() {
const label = document.getElementById('element2-copilot-selected-audio');
if (!label) return;
label.textContent = '截取给 AI 的音频音量';
}
function pageHasParticipant() {
const text = document.body?.innerText || '';
return text.includes('当事人');
}
function pageHasOperator() {
const text = document.body?.innerText || '';
return text.includes('坐席端');
}
function isParticipantAudioItem(candidate) {
return Boolean(
candidate
&& isLive(candidate.track)
&& !candidate.track.muted
&& hasElementReason(candidate, STATIC_AUDIO_ELEMENT_REASON)
&& PARTICIPANT_AUDIO_TRACK_PATTERN.test(candidate.track.id)
);
}
function staticAudioItem() {
return Array.from(remoteAudio.values()).find(isParticipantAudioItem) || null;
}
function gateStatus() {
const item = staticAudioItem();
const participantVisible = pageHasParticipant();
const operatorVisible = pageHasOperator();
return {
bothParties: participantVisible && operatorVisible,
participantOnline: participantVisible && Boolean(item),
operatorOnline: operatorVisible,
senderReady: Boolean(selectedBusinessSender),
track: item?.track || null,
};
}
function gateMessage(status = gateStatus()) {
if (!status.operatorOnline) return '等待坐席在线';
if (!status.participantOnline) return '等待当事人在线';
if (!status.senderReady) return '等待通话上行就绪';
return '就绪:可开启 AI 托管';
}
function updateGateLabel() {
const label = document.getElementById('element2-copilot-gate');
if (!label) return;
const status = gateStatus();
label.textContent = gateMessage(status);
label.style.color = status.participantOnline && status.operatorOnline && status.senderReady ? '#86efac' : '#fcd34d';
}
function scoreAudioCandidate(item) {
if (!item || !isLive(item.track) || item.track.muted || aiOutputTrackIds.has(item.track.id)) return -1;
let score = 0;
if (item.receiver) score += 300;
if ((item.bytesReceived || 0) > 0) score += 100;
if ((item.bytesDelta || 0) > 0) score += 4000 + Math.min(1500, item.bytesDelta);
if ((item.packetsDelta || 0) > 0) score += 2000 + Math.min(800, item.packetsDelta * 8);
score += Math.min(1200, (item.audioLevel || 0) * 12000);
if (elementAudioReasons(item).length > 0) score += 100;
item.audioScore = score;
return score;
}
function rememberRemoteReceiver(receiver, reason) {
const track = receiver?.track;
if (!isLive(track) || isInternalPc(receiver?.transport)) return;
if (track.kind === 'audio' && !aiOutputTrackIds.has(track.id)) {
let item = remoteAudio.get(track.id);
if (!item) {
item = { track, receiver, reasons: new Set(), bytesDelta: 0, packetsDelta: 0, audioLevel: 0, audioScore: 0 };
remoteAudio.set(track.id, item);
log(`audio candidate ${trackLabel(track)} (${reason})`, true);
}
item.track = track;
item.receiver = receiver || item.receiver;
item.reasons.add(reason);
selectRemoteAudio(reason);
}
if (track.kind === 'video') {
let item = remoteVideo.get(track.id);
if (!item) {
item = { track, receiver, reasons: new Set() };
remoteVideo.set(track.id, item);
log(`video candidate ${trackLabel(track)} (${reason})`, true);
}
item.track = track;
item.receiver = receiver || item.receiver;
item.reasons.add(reason);
selectRemoteVideo(reason);
}
}
function rememberRemoteStream(stream, reason) {
if (!stream || !(stream instanceof MediaStream)) return;
for (const track of stream.getAudioTracks()) {
if (aiOutputTrackIds.has(track.id)) continue;
let item = remoteAudio.get(track.id);
if (!item) {
item = { track, receiver: null, reasons: new Set(), bytesDelta: 0, packetsDelta: 0, audioLevel: 0, audioScore: 0 };
remoteAudio.set(track.id, item);
log(`audio stream candidate ${trackLabel(track)} (${reason})`, true);
}
item.track = track;
item.reasons.add(reason);
}
for (const track of stream.getVideoTracks()) {
let item = remoteVideo.get(track.id);
if (!item) {
item = { track, receiver: null, reasons: new Set() };
remoteVideo.set(track.id, item);
log(`video stream candidate ${trackLabel(track)} (${reason})`, true);
}
item.track = track;
item.reasons.add(reason);
}
selectRemoteAudio(reason);
selectRemoteVideo(reason);
}
function selectRemoteAudio(reason) {
const current = selectedRemoteAudio && remoteAudio.get(selectedRemoteAudio.id);
if (isParticipantAudioItem(current)) {
updateGateLabel();
return selectedRemoteAudio;
}
const item = staticAudioItem();
if (!item) {
updateSelectedAudioLabel();
updateGateLabel();
return null;
}
if (selectedRemoteAudio !== item.track) {
selectedRemoteAudio = item.track;
log(`selected input audio (${reason}) from ${STATIC_AUDIO_ELEMENT_REASON}: ${trackLabel(selectedRemoteAudio)}`, true);
updateSelectedAudioLabel();
attachAudioToAi();
}
updateGateLabel();
return selectedRemoteAudio;
}
function selectRemoteVideo(reason) {
const current = selectedRemoteVideo && remoteVideo.get(selectedRemoteVideo.id);
if (isLive(selectedRemoteVideo) && !selectedRemoteVideo.muted && hasElementReason(current, STATIC_VIDEO_ELEMENT_REASON)) {
return selectedRemoteVideo;
}
const item = Array.from(remoteVideo.values()).find((candidate) => (
isLive(candidate.track)
&& !candidate.track.muted
&& hasElementReason(candidate, STATIC_VIDEO_ELEMENT_REASON)
));
if (!item) return null;
if (selectedRemoteVideo !== item.track) {
selectedRemoteVideo = item.track;
log(`selected input video (${reason}) from ${STATIC_VIDEO_ELEMENT_REASON}: ${trackLabel(selectedRemoteVideo)}`, true);
attachVideoToAi();
}
return selectedRemoteVideo;
}
function rememberBusinessSender(sender, track, reason) {
if (!sender) return;
const senderTrack = track || sender.track;
if (senderTrack && senderTrack.kind !== 'audio') return;
const id = sender.__aiElement2GatedSenderId || `sender-${businessSenders.size + 1}`;
sender.__aiElement2GatedSenderId = id;
let item = businessSenders.get(id);
if (!item) {
item = { id, sender, track: senderTrack || null, originalTrack: senderTrack || null, sources: new Set(), bytesDelta: 0, packetsDelta: 0 };
businessSenders.set(id, item);
log(`business sender candidate ${id} (${reason}): ${trackLabel(senderTrack)}`, true);
}
item.sender = sender;
item.track = sender.track || senderTrack || item.track;
if (!isAiTakeover && senderTrack && senderTrack !== aiAudioTrack) item.originalTrack = senderTrack;
item.sources.add(reason);
wrapBusinessSender(sender);
selectBusinessSender(reason);
}
function wrapBusinessSender(sender) {
if (!sender || sender.__aiElement2GatedWrapped) return;
const originalReplaceTrack = sender.replaceTrack?.bind(sender);
if (!originalReplaceTrack) return;
sender.replaceTrack = async function (track) {
const result = await originalReplaceTrack(track);
rememberBusinessSender(sender, track, 'replaceTrack');
return result;
};
sender.__aiElement2GatedWrapped = true;
}
function isRemoteInputTrack(track) {
return Boolean(track && remoteAudio.has(track.id) && !aiOutputTrackIds.has(track.id));
}
function selectBusinessSender(reason) {
if (selectedBusinessSender && !isRemoteInputTrack(selectedBusinessSender.track)) return selectedBusinessSender;
const item = Array.from(businessSenders.values()).find((candidate) => {
const track = candidate.sender.track || candidate.track || candidate.originalTrack;
return candidate.sources.has('pc-addTrack') && !isRemoteInputTrack(track);
});
if (!item) return null;
selectedBusinessSender = item.sender;
originalBusinessTrack = item.originalTrack || item.sender.track || originalBusinessTrack;
log(`selected output sender (${reason}): ${item.id}, track=${trackLabel(item.sender.track)}`, true);
updateGateLabel();
attachAiOutputToBusiness();
return selectedBusinessSender;
}
function setVu(id, level) {
const el = document.getElementById(id);
if (!el) return;
el.style.width = `${Math.min(100, level * 2500)}%`;
el.style.opacity = level > 0 ? '1' : '0.35';
}
function volumePercent(level) {
if (!Number.isFinite(level) || level < 0.0015) return 0;
return Math.round(Math.min(100, level * 2500));
}
function volumeDb(level) {
if (!Number.isFinite(level) || level < 0.0015) return '-inf';
return `${(20 * Math.log10(level)).toFixed(1)}dB`;
}
function volumeText(level) {
return `vol=${volumePercent(level)}% ${volumeDb(level)}`;
}
function setVolumeLabel(id, level) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = volumeText(level);
}
function renderVu() {
refreshAiOutputVuFromAnalyser();
inputVu *= 0.88;
outputVu *= 0.88;
setVu('element2-copilot-input-vu', inputVu);
setVu('element2-copilot-output-vu', outputVu);
setVolumeLabel('element2-copilot-input-volume', inputVu);
setVolumeLabel('element2-copilot-output-volume', outputVu);
requestAnimationFrame(renderVu);
}
function speechVuLevel(level) {
const noiseFloor = 0.0015;
if (!Number.isFinite(level) || level < noiseFloor) return 0;
return Math.min(0.08, (level - noiseFloor) * 3.2);
}
function analyserVuLevel(analyser, data) {
if (!analyser || !data) return 0;
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const centered = (value - 128) / 128;
sum += centered * centered;
}
return speechVuLevel(Math.sqrt(sum / data.length));
}
function refreshAiOutputVuFromAnalyser() {
outputVu = Math.max(outputVu, analyserVuLevel(aiOutputAnalyser, aiOutputAnalyserData));
}
function createAudioContext() {
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
return AudioContextCtor ? new AudioContextCtor() : null;
}
function detachAiOutputMeter() {
try {
aiOutputAnalyserSource?.disconnect();
} catch {}
aiOutputAnalyser = null;
aiOutputAnalyserData = null;
aiOutputAnalyserSource = null;
aiOutputAnalyserTrack = null;
outputVu = 0;
}
async function attachAiOutputMeter(track) {
if (!isLive(track)) {
detachAiOutputMeter();
return false;
}
if (aiOutputAnalyserTrack === track && aiOutputAnalyser) return true;
detachAiOutputMeter();
if (!aiOutputAudioContext) {
aiOutputAudioContext = createAudioContext();
if (!aiOutputAudioContext) {
appendChatLine('音频', '当前浏览器不支持 AudioContext无法显示 AI 输出音量', true);
return false;
}
}
if (aiOutputAudioContext.state === 'suspended') {
await aiOutputAudioContext.resume().catch(() => {});
}
try {
const stream = new MediaStream([track]);
aiOutputAnalyserSource = aiOutputAudioContext.createMediaStreamSource(stream);
aiOutputAnalyser = aiOutputAudioContext.createAnalyser();
aiOutputAnalyser.fftSize = 512;
aiOutputAnalyser.smoothingTimeConstant = 0.55;
aiOutputAnalyserData = new Uint8Array(aiOutputAnalyser.fftSize);
aiOutputAnalyserSource.connect(aiOutputAnalyser);
aiOutputAnalyserTrack = track;
return true;
} catch (error) {
log(`failed to attach AI output meter: ${error.message || error}`);
detachAiOutputMeter();
return false;
}
}
async function refreshStats() {
await refreshInputStats();
await refreshOutputStats();
}
async function refreshInputStats() {
for (const item of remoteAudio.values()) {
if (!item.receiver?.getStats) continue;
const report = await item.receiver.getStats();
report.forEach((stat) => {
const kind = stat.kind || stat.mediaType;
if (stat.type !== 'inbound-rtp' || kind !== 'audio') return;
const prev = inboundMemory.get(stat.id);
const bytes = stat.bytesReceived || 0;
const packets = stat.packetsReceived || 0;
let audioLevel = typeof stat.audioLevel === 'number' ? stat.audioLevel : 0;
if (
prev
&& typeof stat.totalAudioEnergy === 'number'
&& typeof stat.totalSamplesDuration === 'number'
&& typeof prev.energy === 'number'
&& typeof prev.duration === 'number'
) {
const energyDelta = stat.totalAudioEnergy - prev.energy;
const durationDelta = stat.totalSamplesDuration - prev.duration;
if (energyDelta > 0 && durationDelta > 0) {
audioLevel = Math.max(audioLevel, Math.sqrt(energyDelta / durationDelta));
}
}
item.bytesReceived = bytes;
item.packetsReceived = packets;
item.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
item.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
item.audioLevel = audioLevel;
inboundMemory.set(stat.id, {
bytes,
packets,
energy: stat.totalAudioEnergy,
duration: stat.totalSamplesDuration,
});
if (item.track === selectedRemoteAudio) {
inputVu = Math.max(inputVu, speechVuLevel(audioLevel));
}
});
}
selectRemoteAudio('stats');
}
async function refreshOutputStats() {
for (const item of businessSenders.values()) {
if (!item.sender?.getStats) continue;
item.track = item.sender.track || item.track;
const report = await item.sender.getStats();
report.forEach((stat) => {
const kind = stat.kind || stat.mediaType;
if (stat.type !== 'outbound-rtp' || kind !== 'audio') return;
const key = `${item.id}:${stat.id}`;
const prev = outboundMemory.get(key);
const bytes = stat.bytesSent || 0;
const packets = stat.packetsSent || 0;
item.bytesSent = bytes;
item.packetsSent = packets;
item.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
item.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
outboundMemory.set(key, { bytes, packets });
});
}
}
async function outboundBytes(sender) {
if (!sender?.getStats) return null;
let total = 0;
const report = await sender.getStats();
report.forEach((stat) => {
const kind = stat.kind || stat.mediaType;
if (stat.type === 'outbound-rtp' && kind === 'audio') total += stat.bytesSent || 0;
});
return total;
}
function aiState() {
if (!aiPc) return 'pc=null';
return `conn=${aiPc.connectionState},ice=${aiPc.iceConnectionState},signaling=${aiPc.signalingState},remote=${aiPc.remoteDescription?.type || 'none'}`;
}
function aiReady() {
return Boolean(aiPc?.remoteDescription && (
aiPc.connectionState === 'connected'
|| aiPc.iceConnectionState === 'connected'
|| aiPc.iceConnectionState === 'completed'
));
}
function waitForAiReady(timeoutMs = 10000) {
if (aiReady()) return Promise.resolve(true);
return new Promise((resolve) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (aiReady()) {
clearInterval(timer);
resolve(true);
} else if (Date.now() - startedAt >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, 250);
});
}
function checkAiInput(track) {
if (inputHealthTimer) clearTimeout(inputHealthTimer);
inputHealthTimer = setTimeout(async () => {
if (!aiAudioSender || aiAudioSender.track !== track) return;
await waitForAiReady();
const before = await outboundBytes(aiAudioSender);
await new Promise((resolve) => setTimeout(resolve, 2500));
const after = await outboundBytes(aiAudioSender);
log(`AI input packet check: before=${before}, after=${after}, ${aiState()}`, true);
}, 500);
}
function checkAiOutput(track) {
if (outputHealthTimer) clearTimeout(outputHealthTimer);
outputHealthTimer = setTimeout(async () => {
if (!selectedBusinessSender || selectedBusinessSender.track !== track) return;
const before = await outboundBytes(selectedBusinessSender);
await new Promise((resolve) => setTimeout(resolve, 2500));
const after = await outboundBytes(selectedBusinessSender);
log(`AI output packet check: before=${before}, after=${after}, sender=${trackLabel(selectedBusinessSender.track)}`, true);
}, 800);
}
async function attachAudioToAi() {
if (!shouldSendAudioToAi()) return false;
if (!aiAudioSender || !isLive(selectedRemoteAudio)) return false;
if (aiAudioSender.track === selectedRemoteAudio) return true;
await aiAudioSender.replaceTrack(selectedRemoteAudio);
log(`AI input uses ${trackLabel(selectedRemoteAudio)} from ${STATIC_AUDIO_ELEMENT_REASON}`, true);
checkAiInput(selectedRemoteAudio);
return true;
}
async function attachVideoToAi() {
if (!shouldSendVideoToAi()) return false;
if (!aiVideoSender || !isLive(selectedRemoteVideo)) return false;
if (aiVideoSender.track === selectedRemoteVideo) return true;
await aiVideoSender.replaceTrack(selectedRemoteVideo);
log(`AI video uses ${trackLabel(selectedRemoteVideo)} from ${STATIC_VIDEO_ELEMENT_REASON}`, true);
return true;
}
async function attachAiOutputToBusiness() {
selectBusinessSender('before-output');
if (!isAiTakeover || !selectedBusinessSender || !isLive(aiAudioTrack)) {
log(`AI output waiting: takeover=${isAiTakeover}, sender=${Boolean(selectedBusinessSender)}, aiTrack=${trackLabel(aiAudioTrack)}`);
return false;
}
if (selectedBusinessSender.track === aiAudioTrack) return true;
await selectedBusinessSender.replaceTrack(aiAudioTrack);
aiAttached = true;
log(`business sender uses AI audio ${trackLabel(aiAudioTrack)}`, true);
checkAiOutput(aiAudioTrack);
return true;
}
async function restoreBusinessAudio() {
if (!selectedBusinessSender || !aiAttached) return;
await selectedBusinessSender.replaceTrack(originalBusinessTrack || null);
aiAttached = false;
log(`business sender restored ${trackLabel(originalBusinessTrack)}`, true);
}
function wrapTrackListener(listener) {
if (!listener || listener.__aiElement2GatedWrapped) return listener;
const wrapped = function (event) {
const ownerPc = this instanceof RTCPeerConnection ? this : event?.currentTarget;
if (!isInternalPc(ownerPc) && event?.track) {
rememberRemoteReceiver(event.receiver, 'pc-track');
for (const stream of event.streams || []) rememberRemoteStream(stream, 'pc-track-stream');
}
if (typeof listener === 'function') return listener.apply(this, arguments);
if (listener?.handleEvent) return listener.handleEvent(event);
return undefined;
};
wrapped.__aiElement2GatedWrapped = true;
return wrapped;
}
RTCPeerConnection.prototype.addEventListener = function (type, listener, options) {
if (type === 'track') return originalAddEventListener.call(this, type, wrapTrackListener(listener), options);
return originalAddEventListener.call(this, type, listener, options);
};
try {
const descriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack');
Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', {
configurable: true,
enumerable: true,
get() {
return descriptor?.get ? descriptor.get.call(this) : this.__aiElement2GatedOnTrack || null;
},
set(listener) {
const wrapped = wrapTrackListener(listener);
this.__aiElement2GatedOnTrack = wrapped;
if (descriptor?.set) descriptor.set.call(this, wrapped);
else this.addEventListener('track', wrapped);
},
});
} catch (error) {
console.warn('[AI Element2 Gated] failed to wrap ontrack', error);
}
RTCPeerConnection.prototype.addTrack = function (track, ...streams) {
const sender = originalAddTrack.call(this, track, ...streams);
if (!isInternalPc(this) && track?.kind === 'audio') rememberBusinessSender(sender, track, 'pc-addTrack');
return sender;
};
if (originalAddTransceiver) {
RTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
const transceiver = originalAddTransceiver.call(this, trackOrKind, init);
if (!isInternalPc(this)) {
const kind = trackOrKind instanceof MediaStreamTrack ? trackOrKind.kind : trackOrKind;
if (kind === 'audio') {
const track = trackOrKind instanceof MediaStreamTrack ? trackOrKind : transceiver.sender?.track;
rememberBusinessSender(transceiver.sender, track, 'pc-addTransceiver');
}
}
return transceiver;
};
}
RTCPeerConnection.prototype.setRemoteDescription = function () {
const result = originalSetRemoteDescription.apply(this, arguments);
if (!isInternalPc(this)) {
Promise.resolve(result).then(() => {
for (const receiver of this.getReceivers?.() || []) rememberRemoteReceiver(receiver, 'receiver-after-srd');
for (const sender of this.getSenders?.() || []) rememberBusinessSender(sender, sender.track, 'sender-after-srd');
}).catch(() => {});
}
return result;
};
if (mediaSrcObjectDescriptor?.set) {
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
enumerable: mediaSrcObjectDescriptor.enumerable,
get() {
return mediaSrcObjectDescriptor.get.call(this);
},
set(stream) {
if (!this.__aiElement2GatedCopilotElement) {
const reason = elementReasonFor(this) || `${this.tagName.toLowerCase()}-srcObject`;
rememberRemoteStream(stream, reason);
}
return mediaSrcObjectDescriptor.set.call(this, stream);
},
});
}
function scanMediaElements() {
document.querySelectorAll('audio').forEach((element, index) => {
if (element.__aiElement2GatedCopilotElement) return;
rememberRemoteStream(element.srcObject, `element-${index}:audio`);
});
document.querySelectorAll('video').forEach((element, index) => {
if (element.__aiElement2GatedCopilotElement) return;
rememberRemoteStream(element.srcObject, `element-${index}:video`);
});
selectRemoteAudio('media-scan');
selectRemoteVideo('media-scan');
updateGateLabel();
}
setInterval(scanMediaElements, 1000);
async function connectAi(signalingUrl, assistantId) {
scanMediaElements();
selectRemoteAudio('connect');
selectRemoteVideo('connect');
selectBusinessSender('connect');
const status = gateStatus();
updateGateLabel();
if (!status.operatorOnline || !status.participantOnline || !status.senderReady) {
appendChatLine('门槛', gateMessage(status), true);
setStatus('disconnected');
return false;
}
disconnectAi();
selectedRemoteAudio = status.track;
const id = pcId();
aiPc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
markInternalPc(aiPc);
aiPc.onconnectionstatechange = () => log(`AI PC connectionState=${aiPc.connectionState}`);
aiPc.oniceconnectionstatechange = () => log(`AI PC iceConnectionState=${aiPc.iceConnectionState}`);
aiDc = aiPc.createDataChannel('chat');
aiDc.onopen = () => {
setStatus('connected');
log('AI data channel open', true);
aiDc.send(JSON.stringify({ type: 'client-ready' }));
};
aiDc.onclose = () => setStatus('disconnected');
aiDc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
const content = msg.delta || msg.content;
if (msg.type === 'transcript') {
logUserTranscript(content);
} else if (msg.type === 'assistant-text-start') {
startAssistantLine();
} else if (msg.type === 'assistant-text-delta') {
appendAssistantDelta(content);
} else if (msg.type === 'assistant-text-end') {
finishAssistantLine();
} else {
log(`data channel message: ${msg.type}`);
}
} catch {
appendChatLine('DATA', event.data);
}
};
const sendAudio = shouldSendAudioToAi();
const audioTransceiver = sendAudio && isLive(selectedRemoteAudio)
? aiPc.addTransceiver(selectedRemoteAudio, { direction: 'sendrecv' })
: aiPc.addTransceiver('audio', { direction: sendAudio ? 'sendrecv' : 'recvonly' });
aiAudioSender = audioTransceiver.sender;
log(!sendAudio
? 'AI audio uplink disabled by panel setting'
: isLive(selectedRemoteAudio)
? `AI input starts with ${trackLabel(selectedRemoteAudio)} (${STATIC_AUDIO_ELEMENT_REASON})`
: `AI input waiting for ${STATIC_AUDIO_ELEMENT_REASON}`, true);
const sendVideo = shouldSendVideoToAi();
const shouldSendVideo = sendVideo && isLive(selectedRemoteVideo) && !selectedRemoteVideo.muted;
if (shouldSendVideo) {
const videoTransceiver = aiPc.addTransceiver(selectedRemoteVideo, { direction: 'sendonly' });
aiVideoSender = videoTransceiver.sender;
log(`AI video starts with ${trackLabel(selectedRemoteVideo)} (${STATIC_VIDEO_ELEMENT_REASON})`);
} else {
aiVideoSender = null;
log(sendVideo
? `AI video disabled: waiting for live track at ${STATIC_VIDEO_ELEMENT_REASON}`
: 'AI video uplink disabled by panel setting');
}
aiPc.ontrack = (event) => {
if (event.track.kind !== 'audio') return;
aiOutputTrackIds.add(event.track.id);
remoteAudio.delete(event.track.id);
aiAudioTrack = event.track;
attachAiOutputMeter(event.track);
log(`received AI audio ${trackLabel(aiAudioTrack)}`, true);
aiAudioTrack.onunmute = () => {
log(`AI audio unmuted ${trackLabel(aiAudioTrack)}`);
attachAiOutputToBusiness();
};
attachAiOutputToBusiness();
};
aiPc.onicecandidate = (event) => {
if (aiWs?.readyState !== WebSocket.OPEN) return;
aiWs.send(JSON.stringify({
type: 'ice-candidate',
payload: {
pc_id: id,
candidate: event.candidate ? {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
} : null,
},
}));
};
setStatus('connecting');
aiWs = new WebSocket(signalingUrl);
aiWs.onopen = async () => {
const offer = await aiPc.createOffer();
await aiPc.setLocalDescription(offer);
aiWs.send(JSON.stringify({
type: 'offer',
payload: {
pc_id: id,
sdp: aiPc.localDescription.sdp,
type: aiPc.localDescription.type,
assistant_id: assistantId,
vision_enabled: shouldSendVideo,
},
}));
log('sent AI WebRTC offer', true);
};
aiWs.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'answer') {
await aiPc.setRemoteDescription({ type: 'answer', sdp: msg.payload.sdp });
log(`AI WebRTC answer applied; ${aiState()}`, true);
} else if (msg.type === 'ice-candidate' && msg.payload?.candidate) {
await aiPc.addIceCandidate(msg.payload.candidate).catch(() => {});
} else if (msg.type === 'error') {
throw new Error(msg.payload?.message || 'server error');
}
} catch (error) {
log(`signaling error: ${error.message || error}`);
}
};
aiWs.onerror = () => {
setStatus('failed');
log('AI signaling websocket error');
};
aiWs.onclose = () => setStatus('disconnected');
attachAudioToAi();
attachVideoToAi();
return true;
}
function disconnectAi() {
try { aiDc?.close(); } catch {}
try { aiPc?.close(); } catch {}
try { aiWs?.close(); } catch {}
aiDc = null;
aiPc = null;
aiWs = null;
aiAudioSender = null;
aiVideoSender = null;
aiAudioTrack = null;
detachAiOutputMeter();
if (inputRecorder?.state === 'recording') stopInputRecording();
if (videoRecorder?.state === 'recording') stopVideoRecording();
setStatus('disconnected');
}
function setStatus(status) {
const el = document.getElementById('element2-copilot-status');
if (!el) return;
el.textContent = {
disconnected: '未连接',
connecting: '连接中',
connected: '已连接',
failed: '连接失败',
}[status] || status;
}
function updateModeButton() {
const button = document.getElementById('element2-copilot-toggle');
if (!button) return;
if (isAiTakeover) {
button.textContent = '当前AI 接管|点击切回人工';
button.style.background = '#8b5cf6';
} else {
button.textContent = '当前:人工接管|点击切换 AI';
button.style.background = '#10b981';
}
}
window.__aiElement2GatedDump = function () {
const status = gateStatus();
return {
audioSelection: STATIC_AUDIO_ELEMENT_REASON,
staticVideoElement: STATIC_VIDEO_ELEMENT_REASON,
gate: {
bothParties: status.bothParties,
participantOnline: status.participantOnline,
operatorOnline: status.operatorOnline,
senderReady: status.senderReady,
track: trackLabel(status.track),
},
gateMessage: gateMessage(status),
selectedInput: trackLabel(selectedRemoteAudio),
selectedVideo: trackLabel(selectedRemoteVideo),
aiVideoSenderTrack: trackLabel(aiVideoSender?.track),
selectedOutput: trackLabel(selectedBusinessSender?.track),
originalOutput: trackLabel(originalBusinessTrack),
aiAudio: trackLabel(aiAudioTrack),
aiAttached,
aiState: aiState(),
recording: inputRecorder?.state === 'recording' ? {
track: trackLabel(inputRecordingTrack),
seconds: ((Date.now() - inputRecordingStartedAt) / 1000).toFixed(1),
chunks: inputRecordingChunks.length,
} : null,
videoRecording: videoRecorder?.state === 'recording' ? {
track: trackLabel(videoRecordingTrack),
seconds: ((Date.now() - videoRecordingStartedAt) / 1000).toFixed(1),
chunks: videoRecordingChunks.length,
} : null,
inputs: Array.from(remoteAudio.values()).map((item) => ({
selected: item.track === selectedRemoteAudio,
id: item.track.id,
muted: item.track.muted,
live: item.track.readyState,
hasReceiver: Boolean(item.receiver),
source: audioSourceLabel(item),
bytesReceived: item.bytesReceived || 0,
packetsReceived: item.packetsReceived || 0,
bytesDelta: item.bytesDelta,
packetsDelta: item.packetsDelta,
audioLevel: item.audioLevel || 0,
audioScore: Math.round(item.audioScore || scoreAudioCandidate(item) || 0),
matchesStaticElement: hasElementReason(item, STATIC_AUDIO_ELEMENT_REASON),
reasons: Array.from(item.reasons),
})),
videos: Array.from(remoteVideo.values()).map((item) => ({
selected: item.track === selectedRemoteVideo,
id: item.track.id,
muted: item.track.muted,
live: item.track.readyState,
hasReceiver: Boolean(item.receiver),
matchesStaticElement: hasElementReason(item, STATIC_VIDEO_ELEMENT_REASON),
reasons: Array.from(item.reasons),
})),
outputs: Array.from(businessSenders.values()).map((item) => ({
selected: item.sender === selectedBusinessSender,
id: item.id,
track: trackLabel(item.sender.track || item.track),
originalTrack: trackLabel(item.originalTrack),
pcAddTrack: item.sources.has('pc-addTrack'),
isRemoteInput: isRemoteInputTrack(item.sender.track || item.track),
bytesSent: item.bytesSent,
packetsSent: item.packetsSent,
bytesDelta: item.bytesDelta,
packetsDelta: item.packetsDelta,
sources: Array.from(item.sources),
})),
};
};
function renderPanel() {
if (!document.body || document.getElementById('element2-copilot-panel')) return;
const panel = document.createElement('div');
panel.id = 'element2-copilot-panel';
panel.style.cssText = [
'position:fixed',
'top:20px',
'left:20px',
'width:360px',
'z-index:999999',
'box-sizing:border-box',
'padding:16px',
'border-radius:10px',
'background:rgba(17,24,39,.96)',
'color:white',
'font-family:system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'box-shadow:0 12px 32px rgba(0,0,0,.35)',
].join(';');
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;">
<strong style="font-size:15px;color:#f9a8d4;">AI 托管</strong>
<span id="element2-copilot-status" style="font-size:12px;color:#d1d5db;">未连接</span>
</div>
<div style="font-size:11px;color:#9ca3af;margin-bottom:10px;line-height:1.5;">
<span id="element2-copilot-gate" style="color:#fcd34d;">等待当事人在线</span>
</div>
<label style="display:block;font-size:12px;color:#9ca3af;margin-bottom:4px;">信令地址</label>
<input id="element2-copilot-url" value="${DEFAULT_SIGNALING_URL}" style="width:100%;box-sizing:border-box;margin-bottom:10px;padding:8px;border:1px solid #4b5563;border-radius:6px;background:#111827;color:white;">
<label style="display:block;font-size:12px;color:#9ca3af;margin-bottom:4px;">助手 ID</label>
<input id="element2-copilot-assistant" placeholder="请输入助手 ID" style="width:100%;box-sizing:border-box;margin-bottom:12px;padding:8px;border:1px solid #4b5563;border-radius:6px;background:#111827;color:white;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;">
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;font-size:12px;color:#e5e7eb;cursor:pointer;">
<input id="element2-copilot-send-audio" type="checkbox" style="margin:0;">
<span>传音频给 AI</span>
</label>
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;font-size:12px;color:#e5e7eb;cursor:pointer;">
<input id="element2-copilot-send-video" type="checkbox" style="margin:0;">
<span>传视频给 AI</span>
</label>
</div>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;color:#d1fae5;margin-bottom:4px;"><span id="element2-copilot-selected-audio" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">截取给 AI 的音频音量</span><span id="element2-copilot-input-volume" style="flex:0 0 auto;">vol=0% -inf</span></div>
<div style="height:8px;border-radius:999px;overflow:hidden;background:#374151;"><div id="element2-copilot-input-vu" style="height:100%;width:0%;background:linear-gradient(90deg,#10b981,#fcd34d);transition:width .06s linear;"></div></div>
</div>
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;color:#bfdbfe;margin-bottom:4px;"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">AI 发送给当事人的音频音量</span><span id="element2-copilot-output-volume" style="flex:0 0 auto;">vol=0% -inf</span></div>
<div style="height:8px;border-radius:999px;overflow:hidden;background:#374151;"><div id="element2-copilot-output-vu" style="height:100%;width:0%;background:linear-gradient(90deg,#60a5fa,#c084fc);transition:width .06s linear;"></div></div>
</div>
<button id="element2-copilot-toggle" style="width:100%;padding:12px;border:0;border-radius:6px;background:#10b981;color:white;font-weight:700;cursor:pointer;">当前:人工接管|点击切换 AI</button>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<button id="element2-copilot-record" style="width:100%;padding:10px;border:0;border-radius:6px;background:#2563eb;color:white;font-weight:700;cursor:pointer;">录制 AI 输入</button>
<button id="element2-copilot-record-video" style="width:100%;padding:10px;border:0;border-radius:6px;background:#475569;color:white;font-weight:700;cursor:pointer;">录制 AI 视频</button>
</div>
<div id="element2-copilot-log" style="height:170px;margin-top:12px;padding:8px;overflow:auto;border:1px solid #374151;border-radius:6px;background:#030712;color:#a7f3d0;font:11px ui-monospace,Menlo,monospace;">
<span data-placeholder="1" style="color:#6b7280;">等待交互...</span>
</div>
`;
document.body.appendChild(panel);
const toggle = document.getElementById('element2-copilot-toggle');
const recordButton = document.getElementById('element2-copilot-record');
const videoRecordButton = document.getElementById('element2-copilot-record-video');
const urlInput = document.getElementById('element2-copilot-url');
const assistantInput = document.getElementById('element2-copilot-assistant');
const sendAudioInput = document.getElementById('element2-copilot-send-audio');
const sendVideoInput = document.getElementById('element2-copilot-send-video');
sendAudioInput.checked = shouldSendAudioToAi();
sendVideoInput.checked = shouldSendVideoToAi();
sendAudioInput.addEventListener('change', () => {
writeBoolSetting(AUDIO_UPLINK_STORAGE_KEY, sendAudioInput.checked);
log(`AI audio uplink setting: ${sendAudioInput.checked ? 'on' : 'off'}`);
});
sendVideoInput.addEventListener('change', () => {
writeBoolSetting(VIDEO_UPLINK_STORAGE_KEY, sendVideoInput.checked);
log(`AI video uplink setting: ${sendVideoInput.checked ? 'on' : 'off'}`);
});
recordButton.addEventListener('click', toggleInputRecording);
videoRecordButton.addEventListener('click', toggleVideoRecording);
toggle.addEventListener('click', async () => {
if (!isAiTakeover && !assistantInput.value.trim()) {
alert('请先填写助手 ID。');
return;
}
isAiTakeover = !isAiTakeover;
updateModeButton();
if (isAiTakeover) {
const connected = await connectAi(urlInput.value.trim(), assistantInput.value.trim());
if (connected) {
await attachAiOutputToBusiness();
} else {
isAiTakeover = false;
updateModeButton();
}
} else {
await restoreBusinessAudio();
disconnectAi();
}
});
updateModeButton();
updateRecordButton();
updateVideoRecordButton();
updateGateLabel();
}
function renderPanelSoon() {
try { renderPanel(); } catch (error) { console.warn('[AI Element2 Gated] panel error', error); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderPanelSoon, { once: true });
} else {
renderPanelSoon();
}
requestAnimationFrame(renderVu);
setInterval(refreshStats, 250);
setTimeout(renderPanelSoon, 1500);
setTimeout(renderPanelSoon, 3000);
window.addEventListener('beforeunload', () => {
if (inputRecorder?.state === 'recording') stopInputRecording();
if (videoRecorder?.state === 'recording') stopVideoRecording();
});
})();