2026-07-09 08:41:57 +08:00
// ==UserScript==
// @name 12345 AI Element2 Gated Copilot
// @namespace http://tampermonkey.net/
// @version 1.0
2026-07-09 08:54:15 +08:00
// @description Two-party gated bridge: only feed participant audio into AI after both sides are connected.
2026-07-09 08:41:57 +08:00
// @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' ;
2026-07-09 08:54:15 +08:00
const PARTICIPANT_AUDIO_TRACK_PATTERN = /-audio-3-a$/ ;
2026-07-09 08:41:57 +08:00
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 ;
2026-07-09 08:54:15 +08:00
let aiOutputAudioContext = null ;
let aiOutputAnalyser = null ;
let aiOutputAnalyserData = null ;
let aiOutputAnalyserSource = null ;
let aiOutputAnalyserTrack = null ;
2026-07-09 08:41:57 +08:00
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 ;
2026-07-09 08:54:15 +08:00
label . textContent = '截取给 AI 的音频音量' ;
2026-07-09 08:41:57 +08:00
}
2026-07-09 08:54:15 +08:00
function pageHasParticipant () {
2026-07-09 08:41:57 +08:00
const text = document . body ? . innerText || '' ;
2026-07-09 08:54:15 +08:00
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 )
);
2026-07-09 08:41:57 +08:00
}
function staticAudioItem () {
2026-07-09 08:54:15 +08:00
return Array . from ( remoteAudio . values ()). find ( isParticipantAudioItem ) || null ;
2026-07-09 08:41:57 +08:00
}
function gateStatus () {
const item = staticAudioItem ();
2026-07-09 08:54:15 +08:00
const participantVisible = pageHasParticipant ();
const operatorVisible = pageHasOperator ();
2026-07-09 08:41:57 +08:00
return {
2026-07-09 08:54:15 +08:00
bothParties : participantVisible && operatorVisible ,
participantOnline : participantVisible && Boolean ( item ),
operatorOnline : operatorVisible ,
2026-07-09 08:41:57 +08:00
senderReady : Boolean ( selectedBusinessSender ),
track : item ? . track || null ,
};
}
function gateMessage ( status = gateStatus ()) {
2026-07-09 08:54:15 +08:00
if ( ! status . operatorOnline ) return '等待坐席在线' ;
if ( ! status . participantOnline ) return '等待当事人在线' ;
if ( ! status . senderReady ) return '等待通话上行就绪' ;
return '就绪:可开启 AI 托管' ;
2026-07-09 08:41:57 +08:00
}
function updateGateLabel () {
const label = document . getElementById ( 'element2-copilot-gate' );
if ( ! label ) return ;
const status = gateStatus ();
label . textContent = gateMessage ( status );
2026-07-09 08:54:15 +08:00
label . style . color = status . participantOnline && status . operatorOnline && status . senderReady ? '#86efac' : '#fcd34d' ;
2026-07-09 08:41:57 +08:00
}
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 );
2026-07-09 08:54:15 +08:00
if ( isParticipantAudioItem ( current )) {
2026-07-09 08:41:57 +08:00
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 );
2026-07-09 08:54:15 +08:00
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 );
2026-07-09 08:41:57 +08:00
}
function renderVu () {
2026-07-09 08:54:15 +08:00
refreshAiOutputVuFromAnalyser ();
2026-07-09 08:41:57 +08:00
inputVu *= 0.88 ;
outputVu *= 0.88 ;
setVu ( 'element2-copilot-input-vu' , inputVu );
setVu ( 'element2-copilot-output-vu' , outputVu );
2026-07-09 08:54:15 +08:00
setVolumeLabel ( 'element2-copilot-input-volume' , inputVu );
setVolumeLabel ( 'element2-copilot-output-volume' , outputVu );
2026-07-09 08:41:57 +08:00
requestAnimationFrame ( renderVu );
}
2026-07-09 08:54:15 +08:00
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 ;
}
}
2026-07-09 08:41:57 +08:00
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 ) {
2026-07-09 08:54:15 +08:00
inputVu = Math . max ( inputVu , speechVuLevel ( audioLevel ));
2026-07-09 08:41:57 +08:00
}
});
}
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 ();
2026-07-09 08:54:15 +08:00
if ( ! status . operatorOnline || ! status . participantOnline || ! status . senderReady ) {
2026-07-09 08:41:57 +08:00
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 ;
2026-07-09 08:54:15 +08:00
attachAiOutputMeter ( event . track );
2026-07-09 08:41:57 +08:00
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 ;
2026-07-09 08:54:15 +08:00
detachAiOutputMeter ();
2026-07-09 08:41:57 +08:00
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 ,
2026-07-09 08:54:15 +08:00
participantOnline : status . participantOnline ,
operatorOnline : status . operatorOnline ,
2026-07-09 08:41:57 +08:00
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;">
2026-07-09 08:54:15 +08:00
<strong style="font-size:15px;color:#f9a8d4;">AI 托管</strong>
2026-07-09 08:41:57 +08:00
<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;">
2026-07-09 08:54:15 +08:00
<span id="element2-copilot-gate" style="color:#fcd34d;">等待当事人在线</span>
2026-07-09 08:41:57 +08:00
</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;">
2026-07-09 08:54:15 +08:00
<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>
2026-07-09 08:41:57 +08:00
<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;">
2026-07-09 08:54:15 +08:00
<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>
2026-07-09 08:41:57 +08:00
<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 ();
});
})();