Kimi AI Text-to-Speech

Adds text-to-speech to Moonshot's Kimi AI (English voice only).

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Kimi AI Text-to-Speech
// @namespace   http://tampermonkey.net/
// @version     1.5
// @description Adds text-to-speech to Moonshot's Kimi AI (English voice only).
// @author      CHJ85
// @match       https://www.kimi.com/*
// @icon        data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNDMuMDYzMyAzMy4zNjIyQzQ2LjgwMzcgMzUuNDgzMiA0OS4yMDk1IDM5LjEyNSA0OS4yMDk1IDQzLjA2NjRWMzUuODU1MkM0OS4yMDk1IDMxLjY2ODUgNDYuNjA4MyAyNy45NTk0IDQyLjkwNTYgMjYuMzYxM0wzMi4wNDY0IDE5LjY3NUMyNy45NTk3IDE3LjY3NTQgMjMgMjAuOTI1NyAyMyAyNS40MjE4VjQzLjA2NjRDMjMgNDcuNTYyNSAyNy45NTk3IDUwLjYxMjcgMzIuMDQ2NCA0OC44MTI3TDM2LjM3NzYgNDcuMTcyMiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMjMuMTk5MiAzMC4zODQ4QzIzLjE5OTIgMjUuOTM4NyAyNy4xNTg5IDIyLjg4ODYgMzEuMjQ1NyAyNC42ODg2TDM5LjU4NjIgMjguNjA1MkMzOS41ODYyIDMyLjc5MTkgMzYuOTg1IDM2LjUwMSAzMy4zMzAyIDM4LjA5OTJMMjMuMTk5MiAzMC4zODQ4WiIgZmlsbD0iYmxhY2siLz48L3N2Zz4=
// @grant       GM_xmlhttpRequest
// @connect     texttospeech.responsivevoice.org
// @run-at      document-start
// ==/UserScript==

(function() {
    'use strict';

    console.log("Kimi TTS script started (Web Audio API version).");

    // Global state to manage the currently playing audio
    let activePlayback = {
        source: null, context: null, buffer: null, startTime: 0,
        startContextTime: 0, pausedTime: 0, button: null,
        messageElement: null, isPlaying: false, stopReason: null
    };

    // Define common SVG attributes for consistency
    const svgBaseAttributes = {
        width: "28",
        height: "28",
        viewBox: "-10 -12 36 36",
    };

    // --- ICON DEFINITIONS ---
    function createAudioSVG() {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        // Apply base attributes
        for (const attr in svgBaseAttributes) {
            svg.setAttribute(attr, svgBaseAttributes[attr]);
        }
        svg.classList.add("simple-button-icon", "iconify"); // Kimi style class

        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        path.setAttribute("fill", "currentColor");
        path.setAttribute("d", "M14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77M16.5 12c0-1.77-1-3.29-2.5-4.03v8.05c1.5-.74 2.5-2.25 2.5-4.02M3 9v6h4l5 5V4L7 9z");
        svg.appendChild(path);
        return svg;
    }

    function createPauseSVG() {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        // Apply base attributes
        for (const attr in svgBaseAttributes) {
            svg.setAttribute(attr, svgBaseAttributes[attr]);
        }
        svg.classList.add("simple-button-icon", "iconify"); // Kimi style class

        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        path.setAttribute("fill", "currentColor");
        path.setAttribute("d", "M14 19h4V5h-4zm-8 0h4V5H6z");
        svg.appendChild(path);
        return svg;
    }

    // Helper function to swap the icon inside a button
    function replaceButtonIcon(buttonElement, newSvgElement) {
        const currentSvg = buttonElement.querySelector('svg');
        if (currentSvg) {
            // Ensure the new SVG element inherits the necessary attributes for positioning
            for (const attr in svgBaseAttributes) {
                newSvgElement.setAttribute(attr, svgBaseAttributes[attr]);
            }
            buttonElement.replaceChild(newSvgElement, currentSvg);
        } else {
            buttonElement.appendChild(newSvgElement);
        }
    }

    // Function to create the audio button element
    function createAudioButton(messageTextElement, messageElement) {
        const button = document.createElement('div');
        button.classList.add('simple-button', 'size-small', 'kimi-tts-button');
        button.style.cursor = 'pointer'; // Ensure it has a pointer cursor

        // Set the button's background color to light gray
        button.style.backgroundColor = '#ffffff'; // Light gray background for the button itself
        // Set the icon's color to a darker shade for contrast
        button.style.color = '#767676'; // Darker gray for the icon for contrast

        button.style.overflow = 'visible';

        button.appendChild(createAudioSVG());

        button.addEventListener('click', async (e) => {
            e.stopPropagation();
            console.log("Kimi TTS button clicked!");

            const clickedButton = button;
            const clickedMessageElement = messageElement;

            // --- Handle Pause/Resume Logic ---
            if (activePlayback.messageElement === clickedMessageElement) {
                if (activePlayback.isPlaying) {
                    console.log("Pausing playback.");
                    const elapsedContextTime = activePlayback.context.currentTime - activePlayback.startContextTime;
                    activePlayback.pausedTime = activePlayback.startTime + elapsedContextTime;
                    activePlayback.stopReason = 'paused';
                    activePlayback.source.stop();
                    replaceButtonIcon(clickedButton, createAudioSVG());
                    return;
                } else if (activePlayback.pausedTime > 0 && !activePlayback.isPlaying) {
                    console.log("Resuming playback from", activePlayback.pausedTime.toFixed(3), "seconds.");

                    try {
                        const newSource = activePlayback.context.createBufferSource();
                        newSource.buffer = activePlayback.buffer;
                        newSource.connect(activePlayback.context.destination);

                        activePlayback.source = newSource;
                        activePlayback.startTime = activePlayback.pausedTime;
                        activePlayback.startContextTime = activePlayback.context.currentTime;
                        activePlayback.pausedTime = 0;
                        activePlayback.isPlaying = true;

                        newSource.onended = createOnEndedHandler(newSource, clickedButton);
                        newSource.start(0, activePlayback.startTime);
                        replaceButtonIcon(clickedButton, createPauseSVG());
                    } catch (err) {
                        console.error("Error resuming audio:", err);
                    }

                    return;
                }
            }

            // --- Handle Stop Current and Start New Playback ---
            if (activePlayback.source) {
                console.log("Stopping current playback to start new one.");
                if (activePlayback.button) {
                    replaceButtonIcon(activePlayback.button, createAudioSVG());
                }
                activePlayback.stopReason = null;
                activePlayback.source.stop();
            }

            activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };

            console.log("Starting new playback.");
            replaceButtonIcon(clickedButton, createPauseSVG());

            const text = messageTextElement.textContent;
            const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
            const encodedText = encodeURIComponent(cleanedText);
            const audioUrl = `https://texttospeech.responsivevoice.org/v1/text:synthesize?lang=en-GB&engine=g1&pitch=0.5&rate=0.5&volume=1&key=kvfbSITh&gender=male&text=${encodedText}`;

            try {
                const response = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({ method: "GET", url: audioUrl, responseType: "arraybuffer", onload: resolve, onerror: reject, ontimeout: reject });
                });

                if (response.status === 200) {
                    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
                    if (audioContext.state === 'suspended') await audioContext.resume();

                    const audioBuffer = await audioContext.decodeAudioData(response.response);
                    const source = audioContext.createBufferSource();
                    source.buffer = audioBuffer;
                    source.connect(audioContext.destination);

                    activePlayback = {
                        source, context: audioContext, buffer: audioBuffer, startTime: 0,
                        startContextTime: audioContext.currentTime, button: clickedButton,
                        messageElement: clickedMessageElement, isPlaying: true, pausedTime: 0,
                        stopReason: null
                    };

                    source.onended = createOnEndedHandler(source, clickedButton);
                    source.start(0);
                } else { throw new Error(`HTTP status ${response.status}`); }
            } catch (error) {
                console.error("Error fetching or playing audio:", error);
                replaceButtonIcon(clickedButton, createAudioSVG());
                activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
            }
        });
        return button;
    }

    function createOnEndedHandler(sourceNode, buttonElement) {
        return () => {
            if (activePlayback.source !== sourceNode) {
                replaceButtonIcon(buttonElement, createAudioSVG());
                return;
            }
            if (activePlayback.stopReason === 'paused') {
                activePlayback.isPlaying = false;
                activePlayback.source = null;
                activePlayback.stopReason = null;
            } else {
                replaceButtonIcon(buttonElement, createAudioSVG());
                activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
            }
        };
    }

    function processMessage(messageElement) {
        if (messageElement.classList.contains('kimi-audio-added')) return;

        const messageTextElement = messageElement.querySelector('.markdown');
        const actionsContainer = messageElement.querySelector('.segment-assistant-actions-content');

        if (messageTextElement && actionsContainer && messageTextElement.textContent.trim().length > 0) {
            // Ensure the actions container is fully loaded and contains expected elements
            // This check helps prevent adding buttons to incomplete message segments
            if (!actionsContainer.querySelector('[name="Like"]')) {
    // Try again after a short delay to wait for full rendering
    setTimeout(() => processMessage(messageElement), 250);
    return;
}

            const audioButton = createAudioButton(messageTextElement, messageElement);
            const divider = actionsContainer.querySelector('.actions-line');
            if (divider) {
                actionsContainer.insertBefore(audioButton, divider);
            } else {
                // Fallback if divider is not found, append to actionsContainer
                actionsContainer.appendChild(audioButton);
            }
            messageElement.classList.add('kimi-audio-added');
        }
    }

    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    const messageSelector = '.segment-container';
                    if (node.matches(messageSelector)) {
                        processMessage(node);
                    }
                    // Also check for message containers within newly added nodes
                    node.querySelectorAll(messageSelector).forEach(processMessage);
                }
            });
        });
    });

    // Periodically re-scan for unprocessed message elements
    setInterval(() => {
        document.querySelectorAll('.segment-container:not(.kimi-audio-added)').forEach(processMessage);
    }, 1500); // Runs every 1.5 seconds

    // Use 'DOMContentLoaded' instead of 'load' for earlier execution,
    // as the script is set to @run-at document-start
    function initObserver() {
    const targetNode = document.body;
    if (targetNode) {
        observer.observe(targetNode, { childList: true, subtree: true });
        document.querySelectorAll('.segment-container').forEach(processMessage);
    } else {
        console.warn("Target node not found for observer!");
    }
}

if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initObserver);
} else {
    initObserver(); // Already loaded
}
})();