diff --git a/scripts/copilot-native-element.js b/scripts/copilot-native-element.js
deleted file mode 100644
index 61a7042..0000000
--- a/scripts/copilot-native-element.js
+++ /dev/null
@@ -1,1006 +0,0 @@
-// ==UserScript==
-// @name 12345 AI Native Element Copilot
-// @namespace http://tampermonkey.net/
-// @version 1.0
-// @description Native-track bridge with fixed element selection: element-2:audio input, element-0:video vision.
-// @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 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 = 'element-copilot-send-audio';
- const VIDEO_UPLINK_STORAGE_KEY = 'element-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 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;
-
- function log(message) {
- console.log(`[AI Native Element] ${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('element-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('element-copilot-log');
- if (!box) {
- console.log(`[AI Native Element] 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`;
- }
-
- 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('element-copilot-record');
- if (!button) return;
- const recording = inputRecorder?.state === 'recording';
- button.textContent = recording ? '停止录制 AI 输入' : '录制 AI 输入';
- button.style.background = recording ? '#dc2626' : '#2563eb';
- }
-
- 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.__aiNativeElementInternal || 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.__aiNativeElementInternal = 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 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 };
- 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 };
- 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 (isLive(selectedRemoteAudio) && !selectedRemoteAudio.muted && hasElementReason(current, STATIC_AUDIO_ELEMENT_REASON)) {
- return selectedRemoteAudio;
- }
-
- const item = Array.from(remoteAudio.values()).find((candidate) => (
- isLive(candidate.track)
- && !candidate.track.muted
- && hasElementReason(candidate, STATIC_AUDIO_ELEMENT_REASON)
- ));
- if (!item) return null;
-
- if (selectedRemoteAudio !== item.track) {
- selectedRemoteAudio = item.track;
- log(`selected input audio (${reason}) from ${STATIC_AUDIO_ELEMENT_REASON}: ${trackLabel(selectedRemoteAudio)}`, true);
- attachAudioToAi();
- }
- 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.__aiNativeElementSenderId || `sender-${businessSenders.size + 1}`;
- sender.__aiNativeElementSenderId = 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.__aiNativeElementWrapped) 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.__aiNativeElementWrapped = 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);
- attachAiOutputToBusiness();
- return selectedBusinessSender;
- }
-
- function setVu(id, level) {
- const el = document.getElementById(id);
- if (el) el.style.width = `${Math.min(100, level * 2500)}%`;
- }
-
- function renderVu() {
- inputVu *= 0.88;
- outputVu *= 0.88;
- setVu('element-copilot-input-vu', inputVu);
- setVu('element-copilot-output-vu', outputVu);
- requestAnimationFrame(renderVu);
- }
-
- 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;
- 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;
- inboundMemory.set(stat.id, { bytes, packets });
- if (item.track === selectedRemoteAudio) {
- inputVu = Math.max(inputVu, Math.min(0.12, item.bytesDelta / 12000), Math.min(0.04, item.packetsDelta / 500));
- }
- });
- }
- }
-
- 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 });
- if (item.sender === selectedBusinessSender) {
- outputVu = Math.max(outputVu, Math.min(0.12, item.bytesDelta / 12000), Math.min(0.04, item.packetsDelta / 500));
- }
- });
- }
- }
-
- 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.__aiNativeElementWrapped) 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.__aiNativeElementWrapped = 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.__aiNativeElementOnTrack || null;
- },
- set(listener) {
- const wrapped = wrapTrackListener(listener);
- this.__aiNativeElementOnTrack = wrapped;
- if (descriptor?.set) descriptor.set.call(this, wrapped);
- else this.addEventListener('track', wrapped);
- },
- });
- } catch (error) {
- console.warn('[AI Native Element] 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.__aiNativeElementCopilotElement) {
- 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.__aiNativeElementCopilotElement) return;
- rememberRemoteStream(element.srcObject, `element-${index}:audio`);
- });
- document.querySelectorAll('video').forEach((element, index) => {
- if (element.__aiNativeElementCopilotElement) return;
- rememberRemoteStream(element.srcObject, `element-${index}:video`);
- });
- selectRemoteAudio('media-scan');
- selectRemoteVideo('media-scan');
- }
-
- setInterval(scanMediaElements, 1000);
-
- async function connectAi(signalingUrl, assistantId) {
- disconnectAi();
-
- 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);
- }
- };
-
- scanMediaElements();
- selectRemoteAudio('connect');
- selectRemoteVideo('connect');
- selectBusinessSender('connect');
-
- 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;
- 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();
- }
-
- 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;
- if (inputRecorder?.state === 'recording') stopInputRecording();
- setStatus('disconnected');
- }
-
- function setStatus(status) {
- const el = document.getElementById('element-copilot-status');
- if (!el) return;
- el.textContent = {
- disconnected: '未连接',
- connecting: '连接中',
- connected: '已连接',
- failed: '连接失败',
- }[status] || status;
- }
-
- function updateModeButton() {
- const button = document.getElementById('element-copilot-toggle');
- if (!button) return;
- if (isAiTakeover) {
- button.textContent = '当前:AI 接管|点击切回人工';
- button.style.background = '#8b5cf6';
- } else {
- button.textContent = '当前:人工接管|点击切换 AI';
- button.style.background = '#10b981';
- }
- }
-
- window.__aiNativeElementDump = function () {
- return {
- staticAudioElement: STATIC_AUDIO_ELEMENT_REASON,
- staticVideoElement: STATIC_VIDEO_ELEMENT_REASON,
- 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,
- 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),
- matchesStaticElement: hasElementReason(item, STATIC_AUDIO_ELEMENT_REASON),
- bytesReceived: item.bytesReceived || 0,
- packetsReceived: item.packetsReceived || 0,
- bytesDelta: item.bytesDelta,
- packetsDelta: item.packetsDelta,
- 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('element-copilot-panel')) return;
- const panel = document.createElement('div');
- panel.id = 'element-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 = `
-
- AI 元素选轨
- 未连接
-
-
- 音频输入:${STATIC_AUDIO_ELEMENT_REASON}
- 视频输入:${STATIC_VIDEO_ELEMENT_REASON}
-
-
-
-
-
-
-
-
-
-
-
${STATIC_AUDIO_ELEMENT_REASON}原生音轨
-
-
-
-
-
-
- 等待交互...
-
- `;
- document.body.appendChild(panel);
-
- const toggle = document.getElementById('element-copilot-toggle');
- const recordButton = document.getElementById('element-copilot-record');
- const urlInput = document.getElementById('element-copilot-url');
- const assistantInput = document.getElementById('element-copilot-assistant');
- const sendAudioInput = document.getElementById('element-copilot-send-audio');
- const sendVideoInput = document.getElementById('element-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);
- toggle.addEventListener('click', async () => {
- if (!isAiTakeover && !assistantInput.value.trim()) {
- alert('请先填写助手 ID。');
- return;
- }
- isAiTakeover = !isAiTakeover;
- updateModeButton();
- if (isAiTakeover) {
- await connectAi(urlInput.value.trim(), assistantInput.value.trim());
- await attachAiOutputToBusiness();
- } else {
- await restoreBusinessAudio();
- disconnectAi();
- }
- });
- updateModeButton();
- updateRecordButton();
- }
-
- function renderPanelSoon() {
- try { renderPanel(); } catch (error) { console.warn('[AI Native Element] 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();
- });
-})();
diff --git a/scripts/copilot-native-static.js b/scripts/copilot-native-static.js
deleted file mode 100644
index bbee563..0000000
--- a/scripts/copilot-native-static.js
+++ /dev/null
@@ -1,876 +0,0 @@
-// ==UserScript==
-// @name 12345 AI Native Static Copilot
-// @namespace http://tampermonkey.net/
-// @version 1.0
-// @description Native-track bridge with static input/output selection rules.
-// @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 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 ENABLE_VIDEO_UPLINK = true;
-
- 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 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;
-
- function log(message) {
- console.log(`[AI Native Static] ${message}`);
- }
-
- function appendChatLine(role, text, important = false) {
- const box = document.getElementById('static-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('static-copilot-log');
- if (!box) {
- console.log(`[AI Native Static] 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`;
- }
-
- async function startInputRecording() {
- if (inputRecorder?.state === 'recording') return;
- selectRemoteAudio('record');
- if (!isLive(selectedRemoteAudio)) {
- appendChatLine('录音', '没有可录制的远端输入音轨', 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('static-copilot-record');
- if (!button) return;
- const recording = inputRecorder?.state === 'recording';
- button.textContent = recording ? '停止录制 AI 输入' : '录制 AI 输入';
- button.style.background = recording ? '#dc2626' : '#2563eb';
- }
-
- 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.__aiNativeStaticInternal || 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.__aiNativeStaticInternal = true;
- internalPcs.add(pc);
- }
-
- 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 };
- 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);
- if (!selectedRemoteVideo || selectedRemoteVideo.readyState !== 'live') {
- selectedRemoteVideo = track;
- attachVideoToAi();
- }
- }
- }
-
- 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 };
- 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);
- if (!selectedRemoteVideo || selectedRemoteVideo.readyState !== 'live') selectedRemoteVideo = track;
- }
- selectRemoteAudio(reason);
- attachVideoToAi();
- }
-
- function selectRemoteAudio(reason) {
- if (isLive(selectedRemoteAudio) && !selectedRemoteAudio.muted) return selectedRemoteAudio;
-
- const item = Array.from(remoteAudio.values())
- .find((candidate) => isLive(candidate.track) && !candidate.track.muted && candidate.receiver);
- if (!item) return null;
-
- selectedRemoteAudio = item.track;
- log(`selected input audio (${reason}): ${trackLabel(selectedRemoteAudio)}`, true);
- attachAudioToAi();
- return selectedRemoteAudio;
- }
-
- function rememberBusinessSender(sender, track, reason) {
- if (!sender) return;
- const senderTrack = track || sender.track;
- if (senderTrack && senderTrack.kind !== 'audio') return;
-
- const id = sender.__aiNativeStaticSenderId || `sender-${businessSenders.size + 1}`;
- sender.__aiNativeStaticSenderId = 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.__aiNativeStaticWrapped) 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.__aiNativeStaticWrapped = 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);
- attachAiOutputToBusiness();
- return selectedBusinessSender;
- }
-
- function setVu(id, level) {
- const el = document.getElementById(id);
- if (el) el.style.width = `${Math.min(100, level * 2500)}%`;
- }
-
- function renderVu() {
- inputVu *= 0.88;
- outputVu *= 0.88;
- setVu('static-copilot-input-vu', inputVu);
- setVu('static-copilot-output-vu', outputVu);
- requestAnimationFrame(renderVu);
- }
-
- 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;
- 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;
- inboundMemory.set(stat.id, { bytes, packets });
- if (item.track === selectedRemoteAudio) {
- inputVu = Math.max(inputVu, Math.min(0.12, item.bytesDelta / 12000), Math.min(0.04, item.packetsDelta / 500));
- }
- });
- }
- }
-
- 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 });
- if (item.sender === selectedBusinessSender) {
- outputVu = Math.max(outputVu, Math.min(0.12, item.bytesDelta / 12000), Math.min(0.04, item.packetsDelta / 500));
- }
- });
- }
- }
-
- 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 (!aiAudioSender || !isLive(selectedRemoteAudio)) return false;
- if (aiAudioSender.track === selectedRemoteAudio) return true;
- await aiAudioSender.replaceTrack(selectedRemoteAudio);
- log(`AI input uses ${trackLabel(selectedRemoteAudio)}`, true);
- checkAiInput(selectedRemoteAudio);
- return true;
- }
-
- async function attachVideoToAi() {
- if (!aiVideoSender || !isLive(selectedRemoteVideo)) return false;
- if (aiVideoSender.track === selectedRemoteVideo) return true;
- await aiVideoSender.replaceTrack(selectedRemoteVideo);
- log(`AI video uses ${trackLabel(selectedRemoteVideo)}`, 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.__aiNativeStaticWrapped) 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.__aiNativeStaticWrapped = 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.__aiNativeStaticOnTrack || null;
- },
- set(listener) {
- const wrapped = wrapTrackListener(listener);
- this.__aiNativeStaticOnTrack = wrapped;
- if (descriptor?.set) descriptor.set.call(this, wrapped);
- else this.addEventListener('track', wrapped);
- },
- });
- } catch (error) {
- console.warn('[AI Native Static] 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.__aiNativeStaticElement) rememberRemoteStream(stream, `${this.tagName.toLowerCase()}-srcObject`);
- return mediaSrcObjectDescriptor.set.call(this, stream);
- },
- });
- }
-
- setInterval(() => {
- for (const element of document.querySelectorAll('audio, video')) {
- if (!element.__aiNativeStaticElement) rememberRemoteStream(element.srcObject, 'media-scan');
- }
- }, 1000);
-
- async function connectAi(signalingUrl, assistantId) {
- disconnectAi();
-
- 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);
- }
- };
-
- selectRemoteAudio('connect');
- selectBusinessSender('connect');
-
- const audioTransceiver = isLive(selectedRemoteAudio)
- ? aiPc.addTransceiver(selectedRemoteAudio, { direction: 'sendrecv' })
- : aiPc.addTransceiver('audio', { direction: 'sendrecv' });
- aiAudioSender = audioTransceiver.sender;
- log(isLive(selectedRemoteAudio) ? `AI input starts with ${trackLabel(selectedRemoteAudio)}` : 'AI input waiting for selected audio', true);
-
- const shouldSendVideo = ENABLE_VIDEO_UPLINK && isLive(selectedRemoteVideo) && !selectedRemoteVideo.muted;
- if (shouldSendVideo) {
- const videoTransceiver = aiPc.addTransceiver(selectedRemoteVideo, { direction: 'sendonly' });
- aiVideoSender = videoTransceiver.sender;
- log(`AI video starts with ${trackLabel(selectedRemoteVideo)}`);
- } else {
- aiVideoSender = null;
- log('AI video disabled for this offer: no live native video track yet');
- }
-
- aiPc.ontrack = (event) => {
- if (event.track.kind !== 'audio') return;
- aiOutputTrackIds.add(event.track.id);
- remoteAudio.delete(event.track.id);
- aiAudioTrack = 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();
- }
-
- 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;
- if (inputRecorder?.state === 'recording') stopInputRecording();
- setStatus('disconnected');
- }
-
- function setStatus(status) {
- const el = document.getElementById('static-copilot-status');
- if (!el) return;
- el.textContent = {
- disconnected: '未连接',
- connecting: '连接中',
- connected: '已连接',
- failed: '连接失败',
- }[status] || status;
- }
-
- function updateModeButton() {
- const button = document.getElementById('static-copilot-toggle');
- if (!button) return;
- if (isAiTakeover) {
- button.textContent = '当前:AI 接管|点击切回人工';
- button.style.background = '#8b5cf6';
- } else {
- button.textContent = '当前:人工接管|点击切换 AI';
- button.style.background = '#10b981';
- }
- }
-
- window.__aiNativeStaticDump = function () {
- return {
- 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,
- 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),
- bytesReceived: item.bytesReceived || 0,
- packetsReceived: item.packetsReceived || 0,
- bytesDelta: item.bytesDelta,
- packetsDelta: item.packetsDelta,
- 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('static-copilot-panel')) return;
- const panel = document.createElement('div');
- panel.id = 'static-copilot-panel';
- panel.style.cssText = [
- 'position:fixed',
- 'top:20px',
- 'left:20px',
- 'width:340px',
- '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 = `
-
- AI 原生转接
- 未连接
-
-
-
-
-
-
-
-
-
-
- 等待交互...
-
- `;
- document.body.appendChild(panel);
-
- const toggle = document.getElementById('static-copilot-toggle');
- const recordButton = document.getElementById('static-copilot-record');
- const urlInput = document.getElementById('static-copilot-url');
- const assistantInput = document.getElementById('static-copilot-assistant');
- recordButton.addEventListener('click', toggleInputRecording);
- toggle.addEventListener('click', async () => {
- if (!isAiTakeover && !assistantInput.value.trim()) {
- alert('请先填写助手 ID。');
- return;
- }
- isAiTakeover = !isAiTakeover;
- updateModeButton();
- if (isAiTakeover) {
- await connectAi(urlInput.value.trim(), assistantInput.value.trim());
- await attachAiOutputToBusiness();
- } else {
- await restoreBusinessAudio();
- disconnectAi();
- }
- });
- updateModeButton();
- updateRecordButton();
- }
-
- function renderPanelSoon() {
- try { renderPanel(); } catch (error) { console.warn('[AI Native Static] 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();
- });
-})();