diff --git a/scripts/copilot.js b/scripts/copilot.js new file mode 100644 index 0000000..8e98512 --- /dev/null +++ b/scripts/copilot.js @@ -0,0 +1,933 @@ +// ==UserScript== +// @name 12345 AI 协同中台 (WebRTC 洗流桥接版) +// @namespace http://tampermonkey.net/ +// @version 7.5 +// @description 引入 PCM ScriptProcessor 强制软解防丢包Bug;增加真实洗流耳返监听功能,彻底解决远端触发失败悬案 +// @author You +// @match *://yhy-yw.22sj.cn:*/* +// @grant none +// @run-at document-start +// ==/UserScript== + +(function() { + 'use strict'; + + console.log("🚀 [AI Copilot WebRTC] 注入脚本已加载,采用洗流桥接架构 v7.5 (强制 PCM 软解+耳返版)..."); + + // ========================================== + // 1. 全局状态与 WebRTC 核心对象 + // ========================================== + let originalGetUserMedia = null; + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + } + const origAddEventListener = RTCPeerConnection.prototype.addEventListener; + const origSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription; + const origAddStream = RTCPeerConnection.prototype.addStream; + const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject'); + + let audioCtx = null; + let meterAudioCtx = null; + let meterUploadDestination = null; + let micGainNode = null; + let aiOutGainNode = null; + let aiLocalGainNode = null; + + let washedUploadTrack = null; + let keepAliveOscillator = null; + let monitorGainNode = null; // 耳返监听控制器 + + let pc = null; + let wsSignaling = null; + let dc = null; + let audioSender = null; + let videoSender = null; + let analyserNode = null; + + let isAiTakeover = false; + let isMonitoring = false; + + window.__allStolenStreams = new Set(); + window.__ignoredTrackIds = new Set(); + let currentStolenAudioTrack = null; + let currentStolenVideoTrack = null; + let currentMeterSourceNode = null; + let meterKeepAliveGainNode = null; + let pendingBridgeAudioTrack = null; + let statsVuLevel = 0; + const remoteReceiversByTrackId = new Map(); + const receiverStatsMemory = new Map(); + + function isInternalPeerConnection(target) { + return !!(target && target.__aiCopilotInternal); + } + + function generatePcId() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return "PC-" + Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(""); + } + + function isSafeToSteal(stream) { + if (!stream || !(stream instanceof MediaStream)) return false; + if (stream.__internal_bypass) return false; + if (stream.getTracks().length === 0) return false; + for (const track of stream.getTracks()) { + if (window.__ignoredTrackIds.has(track.id)) return false; + } + return true; + } + + function rememberRemoteStream(stream, reason = 'unknown') { + if (!isSafeToSteal(stream)) return; + window.__allStolenStreams.add(stream); + console.log(`[AI Copilot WebRTC] 捕获候选远端流(${reason})`, stream.id, stream.getTracks().map(t => `${t.kind}:${t.readyState}:${t.id}`)); + } + + function rememberRemoteTrack(track, stream, reason = 'track') { + if (!track || window.__ignoredTrackIds.has(track.id)) return; + const targetStream = isSafeToSteal(stream) ? stream : new MediaStream([track]); + rememberRemoteStream(targetStream, reason); + } + + function rememberRemoteReceiver(track, receiver, reason = 'receiver') { + if (!track || !receiver || window.__ignoredTrackIds.has(track.id)) return; + remoteReceiversByTrackId.set(track.id, receiver); + console.log(`[AI Copilot WebRTC] 绑定远端 receiver(${reason})`, track.kind, track.id); + } + + function initMeterAudioCtx() { + if (meterAudioCtx) return; + try { + meterAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); + + meterKeepAliveGainNode = meterAudioCtx.createGain(); + meterKeepAliveGainNode.gain.value = 0; + meterKeepAliveGainNode.connect(meterAudioCtx.destination); + + meterUploadDestination = meterAudioCtx.createMediaStreamDestination(); + meterUploadDestination.stream.__internal_bypass = true; + washedUploadTrack = meterUploadDestination.stream.getAudioTracks()[0]; + window.__ignoredTrackIds.add(washedUploadTrack.id); + + analyserNode = meterAudioCtx.createAnalyser(); + analyserNode.fftSize = 256; + analyserNode.smoothingTimeConstant = 0.35; + + // 耳返监听节点,默认为静音 (0) + monitorGainNode = meterAudioCtx.createGain(); + monitorGainNode.gain.value = 0; + monitorGainNode.connect(meterAudioCtx.destination); + + keepAliveOscillator = meterAudioCtx.createOscillator(); + const oscGain = meterAudioCtx.createGain(); + oscGain.gain.value = 0.0001; + keepAliveOscillator.connect(oscGain); + oscGain.connect(meterUploadDestination); + keepAliveOscillator.start(); + + console.log("🛠️ 洗流音频总线与保活振荡器已创建"); + } catch(e) { + console.warn("VU 探测链路初始化失败:", e); + } + } + + function bridgeRemoteAudioTrack(track, reason = 'scan') { + if (!track || track.readyState !== 'live' || window.__ignoredTrackIds.has(track.id)) return; + if (track === currentStolenAudioTrack && currentMeterSourceNode) return; + pendingBridgeAudioTrack = track; + appendLog(`🎙️ 锁定远端发声轨(${reason}),接入混流总线`, true); + + currentStolenAudioTrack = track; + ensureVuMeterForTrack(track, reason); + } + + // ========================================== + // 🛑 核心神技:PCM Passthrough 强制阻断 Chrome 硬件流 Bug + // ========================================== + let pcmForcerNode = null; + function ensureVuMeterForTrack(track, reason = 'unknown') { + if (!track || track.readyState !== 'live') return; + initMeterAudioCtx(); + + try { + if (currentMeterSourceNode) { + try { currentMeterSourceNode.disconnect(); } catch(e) {} + } + if (pcmForcerNode) { + try { pcmForcerNode.disconnect(); } catch(e) {} + } + + const meterStream = new MediaStream([track]); + meterStream.__internal_bypass = true; + + // 1. 回归 MediaStreamSource,但用 ScriptProcessor 打断它 + currentMeterSourceNode = meterAudioCtx.createMediaStreamSource(meterStream); + + // 2. AGC 放大增益 (击穿 VAD 阈值) + const vadBoostGain = meterAudioCtx.createGain(); + vadBoostGain.gain.value = 8.0; // 放大 8 倍 + currentMeterSourceNode.connect(vadBoostGain); + + // 3. 终极杀招:PCM 强刷节点。迫使浏览器离开底层 WebRTC 硬件直通层,进入 JS 内存读取浮点数! + // 缓存大小 2048/4096 皆可。1进1出。 + pcmForcerNode = meterAudioCtx.createScriptProcessor(4096, 1, 1); + pcmForcerNode.onaudioprocess = function(audioProcessingEvent) { + const inputBuffer = audioProcessingEvent.inputBuffer; + const outputBuffer = audioProcessingEvent.outputBuffer; + // 将输入通道的 float32 数据原封不动写到输出通道 + const inputData = inputBuffer.getChannelData(0); + const outputData = outputBuffer.getChannelData(0); + for (let i = 0; i < inputBuffer.length; i++) { + outputData[i] = inputData[i]; + } + }; + + // 路由:源 -> 放大器 -> PCM强刷节点 -> (VU表 / WebRTC发送端 / 耳返节点 / 垃圾桶) + vadBoostGain.connect(pcmForcerNode); + + pcmForcerNode.connect(analyserNode); + pcmForcerNode.connect(meterUploadDestination); + pcmForcerNode.connect(monitorGainNode); + // 必须连接到音频输出才能触发 onaudioprocess,用 KeepAliveGainNode(增益为0) 当作垃圾桶 + pcmForcerNode.connect(meterKeepAliveGainNode); + + window.__aiCopilotMeterSource = currentMeterSourceNode; + + if (meterAudioCtx.state === 'suspended') { + meterAudioCtx.resume().catch(e => console.warn("VU 音频上下文唤醒失败:", e)); + } + + appendLog(`📈 洗流管道已接管,注入 PCM 强刷节点(${reason})`, true); + } catch(e) { + console.warn("接入混流总线失败:", e); + } + } + + function armAudioContextResume() { + const resume = () => { + [meterAudioCtx, audioCtx].forEach(ctx => { + if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => {}); + }); + }; + document.addEventListener('pointerdown', resume, true); + document.addEventListener('keydown', resume, true); + document.addEventListener('touchstart', resume, true); + } + armAudioContextResume(); + + function bridgeRemoteVideoTrack(track) { + if (!track || track.readyState !== 'live' || track === currentStolenVideoTrack || window.__ignoredTrackIds.has(track.id)) return; + currentStolenVideoTrack = track; + appendLog(`📸 锁定远端视频,开启视觉推流`, true); + const indicator = document.getElementById('video-status-indicator'); + if (indicator) indicator.style.display = 'block'; + if (videoSender) videoSender.replaceTrack(currentStolenVideoTrack).catch(e => console.warn(e)); + } + + function appendLog(message, isImportant = false) { + const logContainer = document.getElementById('ws-log-container'); + if (!logContainer) return; + if (logContainer.children.length === 1 && logContainer.children[0].innerText.includes('等待 DataChannel')) { + logContainer.innerHTML = ''; + } + const logEntry = document.createElement('div'); + logEntry.style.marginBottom = "4px"; + if (isImportant) logEntry.style.color = "#fcd34d"; + + const time = new Date().toLocaleTimeString([], {hour12: false}); + logEntry.textContent = `[${time}] ${message}`; + logContainer.appendChild(logEntry); + + if (logContainer.children.length > 100) { + logContainer.removeChild(logContainer.firstChild); + } + logContainer.scrollTop = logContainer.scrollHeight; + } + + // VU 表渲染 + let vuDataArray = null; + function renderVU() { + let rms = 0; + if (analyserNode) { + if (!vuDataArray || vuDataArray.length !== analyserNode.frequencyBinCount) { + vuDataArray = new Uint8Array(analyserNode.frequencyBinCount); + } + analyserNode.getByteTimeDomainData(vuDataArray); + let sumSquare = 0; + for(let i = 0; i < vuDataArray.length; i++) { + const val = (vuDataArray[i] - 128) / 128; + sumSquare += val * val; + } + rms = Math.sqrt(sumSquare / vuDataArray.length); + } + rms = Math.max(rms, statsVuLevel); + statsVuLevel *= 0.88; + const vuBar = document.getElementById('upload-vu-bar'); + if (vuBar) { + const width = Math.min(100, rms * 2500); + vuBar.style.width = width + '%'; + } + requestAnimationFrame(renderVU); + } + requestAnimationFrame(renderVU); + + setInterval(async () => { + const receivers = Array.from(remoteReceiversByTrackId.values()); + let bestLevel = 0; + for (const receiver of receivers) { + try { + if (!receiver.track || receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue; + const report = await receiver.getStats(); + report.forEach(stat => { + const statKind = stat.kind || stat.mediaType; + if (stat.type !== 'inbound-rtp' || statKind !== 'audio') return; + if (typeof stat.audioLevel === 'number') { + bestLevel = Math.max(bestLevel, stat.audioLevel); + } + if (typeof stat.totalAudioEnergy === 'number' && typeof stat.totalSamplesDuration === 'number') { + const prev = receiverStatsMemory.get(stat.id); + receiverStatsMemory.set(stat.id, { + energy: stat.totalAudioEnergy, + duration: stat.totalSamplesDuration + }); + if (prev) { + const energyDelta = stat.totalAudioEnergy - prev.energy; + const durationDelta = stat.totalSamplesDuration - prev.duration; + if (energyDelta > 0 && durationDelta > 0) { + bestLevel = Math.max(bestLevel, Math.sqrt(energyDelta / durationDelta)); + } + } + } + }); + } catch(e) {} + } + if (bestLevel > 0) statsVuLevel = Math.max(statsVuLevel, bestLevel); + }, 250); + + // ========================================== + // 2. 精准声源窃取拦截器 + // ========================================== + function wrapTrackListener(target, listener) { + if (!listener || listener.__aiCopilotWrappedTrackListener) return listener; + const wrapped = function(event) { + if (!isInternalPeerConnection(this) && event && event.track) { + rememberRemoteTrack(event.track, event.streams && event.streams[0], 'pc-track'); + rememberRemoteReceiver(event.track, event.receiver, 'pc-track'); + } + if (typeof listener === 'function') return listener.apply(this, arguments); + if (typeof listener === 'object' && listener.handleEvent) return listener.handleEvent(event); + }; + wrapped.__aiCopilotWrappedTrackListener = true; + wrapped.__aiCopilotOriginalTrackListener = listener; + return wrapped; + } + + function wrapAddStreamListener(listener) { + if (!listener || listener.__aiCopilotWrappedAddStreamListener) return listener; + const wrapped = function(event) { + if (!isInternalPeerConnection(this) && event && event.stream) { + rememberRemoteStream(event.stream, 'pc-addstream'); + } + if (typeof listener === 'function') return listener.apply(this, arguments); + if (typeof listener === 'object' && listener.handleEvent) return listener.handleEvent(event); + }; + wrapped.__aiCopilotWrappedAddStreamListener = true; + return wrapped; + } + + RTCPeerConnection.prototype.addEventListener = function(type, listener, options) { + if (type === 'track') { + return origAddEventListener.call(this, type, wrapTrackListener(this, listener), options); + } + if (type === 'addstream') { + return origAddEventListener.call(this, type, wrapAddStreamListener(listener), options); + } + return origAddEventListener.call(this, type, listener, options); + }; + + try { + const onTrackDescriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack'); + Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', { + configurable: true, + enumerable: true, + get: function() { + return onTrackDescriptor && onTrackDescriptor.get ? onTrackDescriptor.get.call(this) : this.__aiCopilotOnTrack || null; + }, + set: function(listener) { + const wrapped = wrapTrackListener(this, listener); + this.__aiCopilotOnTrack = wrapped; + if (onTrackDescriptor && onTrackDescriptor.set) { + onTrackDescriptor.set.call(this, wrapped); + } else { + this.addEventListener('track', wrapped); + } + } + }); + } catch (e) { + console.warn("ontrack 拦截安装失败:", e); + } + + try { + const onAddStreamDescriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'onaddstream'); + Object.defineProperty(RTCPeerConnection.prototype, 'onaddstream', { + configurable: true, + enumerable: true, + get: function() { + return onAddStreamDescriptor && onAddStreamDescriptor.get ? onAddStreamDescriptor.get.call(this) : this.__aiCopilotOnAddStream || null; + }, + set: function(listener) { + const wrapped = wrapAddStreamListener(listener); + this.__aiCopilotOnAddStream = wrapped; + if (onAddStreamDescriptor && onAddStreamDescriptor.set) { + onAddStreamDescriptor.set.call(this, wrapped); + } else { + this.addEventListener('addstream', wrapped); + } + } + }); + } catch (e) { + console.warn("onaddstream 拦截安装失败:", e); + } + + RTCPeerConnection.prototype.setRemoteDescription = function() { + const ret = origSetRemoteDescription.apply(this, arguments); + if (!isInternalPeerConnection(this)) { + Promise.resolve(ret).then(() => { + try { + this.getReceivers?.().forEach(receiver => { + if (receiver.track) { + rememberRemoteTrack(receiver.track, new MediaStream([receiver.track]), 'receiver-after-srd'); + rememberRemoteReceiver(receiver.track, receiver, 'receiver-after-srd'); + } + }); + this.getRemoteStreams?.().forEach(stream => rememberRemoteStream(stream, 'remote-stream-after-srd')); + } catch (e) {} + }); + } + return ret; + }; + + if (origAddStream) { + RTCPeerConnection.prototype.addStream = function(stream) { + if (!isInternalPeerConnection(this)) rememberRemoteStream(stream, 'pc-addStream-call'); + return origAddStream.apply(this, arguments); + }; + } + + if (mediaSrcObjectDescriptor && mediaSrcObjectDescriptor.set) { + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + enumerable: mediaSrcObjectDescriptor.enumerable, + get: function() { + return mediaSrcObjectDescriptor.get.call(this); + }, + set: function(stream) { + if ((this.tagName === 'AUDIO' || this.tagName === 'VIDEO') && !this.__aiCopilotInternalElement) { + rememberRemoteStream(stream, 'media-srcObject'); + } + return mediaSrcObjectDescriptor.set.call(this, stream); + } + }); + } + + const origPlay = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function() { + if (!this.muted && this.volume > 0 && isSafeToSteal(this.srcObject)) { + rememberRemoteStream(this.srcObject, 'media-play'); + } + return origPlay.apply(this, arguments); + }; + + const AudioContextCtor = window.AudioContext || window.webkitAudioContext; + const origCreateMediaStreamSource = AudioContextCtor?.prototype?.createMediaStreamSource; + if (AudioContextCtor && origCreateMediaStreamSource) { + AudioContextCtor.prototype.createMediaStreamSource = function(stream) { + if (isSafeToSteal(stream)) { + rememberRemoteStream(stream, 'audio-context-source'); + } + return origCreateMediaStreamSource.call(this, stream); + }; + } + + setInterval(() => { + document.querySelectorAll('audio, video').forEach(el => { + if (!el.muted && el.volume > 0 && isSafeToSteal(el.srcObject)) { + rememberRemoteStream(el.srcObject, 'media-scan'); + } + }); + + let foundVideoTrack = null; + let foundAudioTrack = null; + + const mediaEls = document.querySelectorAll('audio, video'); + for (let el of mediaEls) { + if (el.__aiCopilotInternalElement) continue; + if (!el.muted && el.volume > 0 && isSafeToSteal(el.srcObject)) { + if (!foundVideoTrack) { + const vTracks = el.srcObject.getVideoTracks().filter(t => t.readyState === 'live'); + if (vTracks.length > 0) foundVideoTrack = vTracks[0]; + } + if (!foundAudioTrack) { + const aTracks = el.srcObject.getAudioTracks().filter(t => t.readyState === 'live'); + if (aTracks.length > 0) foundAudioTrack = aTracks[0]; + } + } + } + + if (!foundAudioTrack || !foundVideoTrack) { + for (const stream of Array.from(window.__allStolenStreams)) { + if (!foundVideoTrack) { + const videoTracks = stream.getVideoTracks().filter(t => t.readyState === 'live'); + if (videoTracks.length > 0) foundVideoTrack = videoTracks[0]; + } + if (!foundAudioTrack) { + const audioTracks = stream.getAudioTracks().filter(t => t.readyState === 'live'); + if (audioTracks.length > 0) foundAudioTrack = audioTracks[0]; + } + if (foundVideoTrack && foundAudioTrack) break; + } + } + + bridgeRemoteVideoTrack(foundVideoTrack); + bridgeRemoteAudioTrack(foundAudioTrack, 'scan'); + }, 1000); + + // ========================================== + // 3. 覆写 getUserMedia 构建本地混音路由 + // ========================================== + if (originalGetUserMedia) { + navigator.mediaDevices.getUserMedia = async function (constraints) { + if (constraints.video && !constraints.audio) { + renderControlPanelSoon(); + return originalGetUserMedia(constraints); + } + + try { + const realStream = await originalGetUserMedia(constraints); + realStream.__internal_bypass = true; + realStream.getTracks().forEach(t => window.__ignoredTrackIds.add(t.id)); + + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + + const mixedDest = audioCtx.createMediaStreamDestination(); + mixedDest.stream.__internal_bypass = true; + mixedDest.stream.getTracks().forEach(t => window.__ignoredTrackIds.add(t.id)); + + if (realStream.getAudioTracks().length > 0) { + const localMicSource = audioCtx.createMediaStreamSource(realStream); + + micGainNode = audioCtx.createGain(); + micGainNode.gain.value = isAiTakeover ? 0 : 1.0; + localMicSource.connect(micGainNode); + micGainNode.connect(mixedDest); + } + + aiOutGainNode = audioCtx.createGain(); + aiOutGainNode.gain.value = isAiTakeover ? 1.0 : 0; + aiOutGainNode.connect(mixedDest); + + aiLocalGainNode = audioCtx.createGain(); + aiLocalGainNode.gain.value = isAiTakeover ? 1.0 : 0; + aiLocalGainNode.connect(audioCtx.destination); + + if (constraints.video && realStream.getVideoTracks().length > 0) { + mixedDest.stream.addTrack(realStream.getVideoTracks()[0]); + } + + if (pendingBridgeAudioTrack && pendingBridgeAudioTrack.readyState === 'live') { + bridgeRemoteAudioTrack(pendingBridgeAudioTrack, 'pending'); + } + + renderControlPanelSoon(); + return mixedDest.stream; + } catch (err) { + console.error("🚨 拦截失败:", err); + return originalGetUserMedia(constraints); + } + }; + } + + // ========================================== + // 4. WebRTC 核心信令 + // ========================================== + async function connectWebRTC(wsUrl, assistantId) { + disconnectWebRTC(); + + try { + const pcId = generatePcId(); + pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); + pc.__aiCopilotInternal = true; + const statusEl = document.getElementById('ws-status'); + if(statusEl) statusEl.textContent = "🟡 协商中..."; + appendLog("🔄 正在建立 WebRTC 连接..."); + + initMeterAudioCtx(); + const activeStream = new MediaStream([washedUploadTrack]); + const audioTransceiver = pc.addTransceiver(washedUploadTrack, { + direction: 'sendrecv', + streams: [activeStream] + }); + audioSender = audioTransceiver.sender; + appendLog("🎤 已挂载洗流音频管线 (附带 PCM 强刷保障)", true); + + const videoTransceiver = pc.addTransceiver('video', { direction: 'sendonly' }); + videoSender = videoTransceiver.sender; + if (currentStolenVideoTrack) { + videoSender.replaceTrack(currentStolenVideoTrack).catch(e=>console.warn(e)); + } + + pc.ontrack = (e) => { + if (e.track.kind === 'audio' && audioCtx) { + appendLog("🎧 接收到原生 AI 语音流,接入本地拓扑", true); + + window.__ignoredTrackIds.add(e.track.id); + const aiStream = new MediaStream([e.track]); + aiStream.__internal_bypass = true; + + const aiStreamSource = audioCtx.createMediaStreamSource(aiStream); + aiStreamSource.connect(aiOutGainNode); + aiStreamSource.connect(aiLocalGainNode); + window.__aiStreamSource = aiStreamSource; + } + }; + + dc = pc.createDataChannel("chat"); + dc.onopen = () => { + const statusEl = document.getElementById('ws-status'); + if(statusEl) statusEl.textContent = "🟢 已连接"; + appendLog("✅ WebRTC 数据通道建立成功"); + dc.send(JSON.stringify({ type: "client-ready" })); + }; + dc.onclose = () => { + const statusEl = document.getElementById('ws-status'); + if(statusEl) statusEl.textContent = "🔴 未连接"; + appendLog("❌ WebRTC 连接断开"); + }; + dc.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'assistant-text-delta' || msg.type === 'transcript') { + const content = msg.delta || msg.content; + if (content && content.trim()) appendLog(`💬 AI: ${content}`); + } else if (msg.type === 'node-active') { + appendLog(`🔄 工作流流转至节点: ${msg.nodeId}`, true); + } else if (msg.type !== 'assistant-text-start' && msg.type !== 'assistant-text-end') { + appendLog(`📥 收到信令: ${msg.type}`); + } + } catch (e) { + appendLog(`📥 收到文本: ${event.data}`); + } + }; + + wsSignaling = new WebSocket(wsUrl); + wsSignaling.onmessage = async (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "answer") { + await pc.setRemoteDescription({ type: "answer", sdp: msg.payload.sdp }); + appendLog("📤 SDP 握手成功,底层引擎接管音视频传输", true); + } else if (msg.type === "ice-candidate" && msg.payload?.candidate) { + try { await pc.addIceCandidate(msg.payload.candidate); } catch(e){} + } else if (msg.type === "error") { + throw new Error(msg.payload?.message || "后端报错"); + } + } catch (e) { + appendLog("⚠️ 信令处理错误: " + e.message); + } + }; + + wsSignaling.onopen = async () => { + pc.onicecandidate = (e) => { + if (wsSignaling.readyState !== WebSocket.OPEN) return; + wsSignaling.send(JSON.stringify({ + type: "ice-candidate", + payload: { + pc_id: pcId, + candidate: e.candidate ? { + candidate: e.candidate.candidate, + sdpMid: e.candidate.sdpMid, + sdpMLineIndex: e.candidate.sdpMLineIndex + } : null + } + })); + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + wsSignaling.send(JSON.stringify({ + type: "offer", + payload: { + pc_id: pcId, + sdp: pc.localDescription.sdp, + type: pc.localDescription.type, + assistant_id: assistantId, + vision_enabled: true + } + })); + }; + + wsSignaling.onerror = () => appendLog("⚠️ WebSocket 信令连接失败"); + + } catch (e) { + console.error(e); + appendLog("⚠️ 连接失败: " + e.message); + const statusEl = document.getElementById('ws-status'); + if (statusEl) statusEl.textContent = "🔴 连接失败"; + } + } + + function disconnectWebRTC() { + if (dc) { + try { dc.close(); } catch(e){} + dc = null; + } + if (pc) { + pc.close(); + pc = null; + } + if (wsSignaling) { + try { wsSignaling.close(); } catch(e){} + wsSignaling = null; + } + const statusEl = document.getElementById('ws-status'); + if (statusEl) statusEl.textContent = "🔴 未连接"; + appendLog("⏹️ 已断开 WebRTC 引擎连接"); + } + + // ========================================== + // 5. 渲染控制面板 + // ========================================== + function renderControlPanel() { + if (document.getElementById('ai-copilot-panel')) return; + if (!document.body) return; + + const panel = document.createElement('div'); + panel.id = 'ai-copilot-panel'; + panel.style.cssText = ` + position: fixed; top: 20px; left: 20px; + background: rgba(17, 24, 39, 0.95); color: white; + padding: 20px; border-radius: 12px; z-index: 999999; + font-family: sans-serif; box-shadow: 0 10px 25px rgba(0,0,0,0.5); + width: 340px; backdrop-filter: blur(10px); + max-height: 90vh; overflow-y: auto; + `; + + panel.innerHTML = ` +
+

🤖 AI WebRTC 引擎枢纽

+ 🔴 未连接 +
+ +
+ + +
+ +
+ + +
+ +
+
+ 当事人声音 (AI入站音量) + +
+
+
+
+
+ + + +
+ +
+ +
+ + +
+ +
+

DataChannel 信令日志

+
+ 等待 DataChannel 消息... +
+
+ `; + + document.body.appendChild(panel); + + const btnToggleTakeover = document.getElementById('btn-toggle-takeover'); + const btnTestText = document.getElementById('btn-test-text'); + const btnTestVoice = document.getElementById('btn-test-voice'); + const btnToggleMonitor = document.getElementById('btn-toggle-monitor'); + const urlInput = document.getElementById('ws-url-input'); + const assistantIdInput = document.getElementById('assistant-id-input'); + + // 耳返功能(直接排查法) + btnToggleMonitor.addEventListener('click', () => { + if (!monitorGainNode) return alert("总线尚未初始化!"); + isMonitoring = !isMonitoring; + monitorGainNode.gain.value = isMonitoring ? 1.0 : 0; + if (isMonitoring) { + btnToggleMonitor.textContent = "🎧 返听已开"; + btnToggleMonitor.style.background = "#10b981"; + appendLog("🎧 已开启硬件级返听,耳机听到的即是发给 AI 的声音", true); + } else { + btnToggleMonitor.textContent = "🎧 返听已关"; + btnToggleMonitor.style.background = "#ef4444"; + appendLog("🔇 返听已关闭"); + } + }); + + btnTestText.addEventListener('click', () => { + if (!dc || dc.readyState !== 'open') { + alert("⚠️ DataChannel 尚未连接,请先点击【AI 托管】。"); + return; + } + const testText = "你好,你是谁"; + dc.send(JSON.stringify({ type: "user-text", text: testText })); + appendLog(`📝 发送强制测音文本: ${testText}`, true); + }); + + btnTestVoice.addEventListener('click', () => { + if (!meterAudioCtx || !meterUploadDestination) { + alert("⚠️ 洗流总线未初始化,请先进行【AI 托管】连接。"); + return; + } + if (meterAudioCtx.state === 'suspended') { + meterAudioCtx.resume(); + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'audio/*'; + fileInput.style.display = 'none'; + + fileInput.onchange = (e) => { + const file = e.target.files[0]; + if (!file) { + fileInput.remove(); + return; + } + + appendLog(`🔄 正在加载本地音频: ${file.name}...`); + const blobUrl = URL.createObjectURL(file); + + const audioEl = new Audio(); + audioEl.src = blobUrl; + + const fileSource = meterAudioCtx.createMediaElementSource(audioEl); + fileSource.connect(meterUploadDestination); + if (analyserNode) fileSource.connect(analyserNode); + fileSource.connect(meterAudioCtx.destination); + + audioEl.play().then(() => { + appendLog(`🗣️ 已成功将本地音频注入 WebRTC 音轨: ${file.name}`, true); + }).catch(err => { + appendLog(`❌ 注入音频播放失败: ${err.message}`); + console.error("本地音频播放错误:", err); + }); + + audioEl.onended = () => { + URL.revokeObjectURL(blobUrl); + appendLog(`⏹️ 本地音频播报完毕: ${file.name}`); + }; + + fileInput.remove(); + }; + + document.body.appendChild(fileInput); + fileInput.click(); + }); + + btnToggleTakeover.addEventListener('click', () => { + if (!audioCtx) { + alert("音频路由未初始化!请先在网页内加入会议。"); + return; + } + + const asstId = assistantIdInput.value.trim(); + if (!isAiTakeover && !asstId) { + alert("请先填写 Assistant ID 再发起连接!"); + return; + } + + isAiTakeover = !isAiTakeover; + const now = audioCtx.currentTime; + + if (isAiTakeover) { + if(micGainNode) micGainNode.gain.setValueAtTime(0, now); + if(aiOutGainNode) aiOutGainNode.gain.setValueAtTime(1.0, now); + if(aiLocalGainNode) aiLocalGainNode.gain.setValueAtTime(1.0, now); + + btnToggleTakeover.textContent = "🤖 AI 托管中 (点击切回人工)"; + btnToggleTakeover.style.background = "#8b5cf6"; + + if (!pc || pc.connectionState !== 'connected') { + if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); + if (meterAudioCtx && meterAudioCtx.state === 'suspended') { + meterAudioCtx.resume().catch(e => console.warn("meterAudioCtx 唤醒失败", e)); + } + connectWebRTC(urlInput.value, asstId); + } + } else { + if(micGainNode) micGainNode.gain.setValueAtTime(1.0, now); + if(aiOutGainNode) aiOutGainNode.gain.setValueAtTime(0, now); + if(aiLocalGainNode) aiLocalGainNode.gain.setValueAtTime(0, now); + + btnToggleTakeover.textContent = "👨‍💼 人工通话中 (点击 AI 托管)"; + btnToggleTakeover.style.background = "#10b981"; + + disconnectWebRTC(); + } + }); + } + + function renderControlPanelSoon() { + try { renderControlPanel(); } catch(e) {} + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderControlPanelSoon, { once: true }); + } else { + renderControlPanelSoon(); + } + + setTimeout(renderControlPanelSoon, 1500); + setTimeout(renderControlPanelSoon, 3000); + + // ========================================== + // 🛑 UI 守护进程 + // ========================================== + setInterval(() => { + if (document.body && !document.getElementById('ai-copilot-panel')) { + console.log("⚠️ [AI Copilot] 检测到面板被网页框架覆盖,正在重新挂载..."); + renderControlPanelSoon(); + + if (pc && pc.connectionState === 'connected') { + const statusEl = document.getElementById('ws-status'); + if (statusEl) statusEl.textContent = "🟢 已连接"; + const btn = document.getElementById('btn-toggle-takeover'); + if (btn && isAiTakeover) { + btn.textContent = "🤖 AI 托管中 (点击切回人工)"; + btn.style.background = "#8b5cf6"; + } + } + } + }, 2000); + +})(); \ No newline at end of file