// SillyBubble - Dynamic Chat Bubble Image Generation Extension import { extension_settings, getContext } from "../../../extensions.js"; import { saveSettingsDebounced, callPopup } from "../../../../script.js"; // Extension configuration const extensionName = "SillyBubble"; const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`; // Default settings const defaultSettings = { image_service_url: "http://calista.the.sexiest.cat/image.php", // Use fully qualified URL by default default_style: "default", default_character: "Example", default_bubble_type: "speech", enabled: true, render_in_collapse: true, // Setting to enable/disable rendering in collapsed tool calls use_character_param: false // Whether to expose the character parameter to the AI }; // Make sure settings exist if (!extension_settings[extensionName]) { extension_settings[extensionName] = {}; } // Apply defaults for any missing settings if (Object.keys(extension_settings[extensionName]).length === 0) { Object.assign(extension_settings[extensionName], defaultSettings); } // Validate settings - ensure image URL is absolute if (extension_settings[extensionName].image_service_url && !extension_settings[extensionName].image_service_url.startsWith('http://') && !extension_settings[extensionName].image_service_url.startsWith('https://')) { console.warn(`[${extensionName}] Saved image URL is not absolute, updating to use http://`); extension_settings[extensionName].image_service_url = `http://${extension_settings[extensionName].image_service_url}`; } // Function for AI to call - generates markdown image with URL-encoded text function generateChatBubbleImage(text, style, character, bubble_type) { if (!extension_settings[extensionName].enabled) { return text; } // Use default values if parameters not specified const bubbleStyle = style || extension_settings[extensionName].default_style; const characterName = character || extension_settings[extensionName].default_character; const bubbleType = bubble_type || extension_settings[extensionName].default_bubble_type; // URL encode the text parameter const encodedText = encodeURIComponent(text); // Ensure the URL is absolute (starts with http:// or https://) let serviceUrl = extension_settings[extensionName].image_service_url; if (!serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) { console.warn(`[${extensionName}] Image service URL is not absolute: ${serviceUrl}`); serviceUrl = `http://${serviceUrl}`; } // Construct the URL with the encoded text let imageUrl = `${serviceUrl}?q=${encodedText}`; // Add parameters if they differ from defaults if (characterName && characterName !== "Example") { imageUrl += `&character=${characterName}`; } if (bubbleType && bubbleType !== "speech") { imageUrl += `&bubble_type=${bubbleType}`; } // Add style parameter if specified (for backward compatibility) if (bubbleStyle && bubbleStyle !== "default" && !characterName) { imageUrl += `&style=${bubbleStyle}`; } console.log(`[${extensionName}] Generated image URL: ${imageUrl}`); // Return markdown image format return `![](${imageUrl})`; } // Load extension settings into UI function loadSettings() { // Update UI with current settings $("#sillybubble_enabled").prop("checked", extension_settings[extensionName].enabled).trigger("input"); $("#sillybubble_image_url").val(extension_settings[extensionName].image_service_url).trigger("input"); $("#sillybubble_default_style").val(extension_settings[extensionName].default_style).trigger("input"); $("#sillybubble_default_character").val(extension_settings[extensionName].default_character).trigger("input"); $("#sillybubble_default_bubble_type").val(extension_settings[extensionName].default_bubble_type).trigger("input"); $("#sillybubble_render_in_collapse").prop("checked", extension_settings[extensionName].render_in_collapse).trigger("input"); $("#sillybubble_use_character_param").prop("checked", extension_settings[extensionName].use_character_param).trigger("input"); } // Handle enable/disable toggle function onEnabledInput(event) { const value = Boolean($(event.target).prop("checked")); extension_settings[extensionName].enabled = value; saveSettingsDebounced(); } // Handle image service URL changes function onImageUrlInput(event) { let value = $(event.target).val().trim(); // Ensure URL is absolute if (value && !value.startsWith('http://') && !value.startsWith('https://')) { console.warn(`[${extensionName}] Entered URL is not absolute, updating: ${value}`); value = `http://${value}`; $(event.target).val(value); } extension_settings[extensionName].image_service_url = value; saveSettingsDebounced(); } // Handle default style changes function onDefaultStyleInput(event) { const value = $(event.target).val(); extension_settings[extensionName].default_style = value; saveSettingsDebounced(); } // Handle default character changes function onDefaultCharacterInput(event) { const value = $(event.target).val(); extension_settings[extensionName].default_character = value; saveSettingsDebounced(); } // Handle default bubble type changes function onDefaultBubbleTypeInput(event) { const value = $(event.target).val(); extension_settings[extensionName].default_bubble_type = value; saveSettingsDebounced(); } // Handle render in collapse toggle function onRenderInCollapseInput(event) { const value = Boolean($(event.target).prop("checked")); extension_settings[extensionName].render_in_collapse = value; saveSettingsDebounced(); // Re-process messages when the setting is toggled processToolCallMessages(); } // Handle use character parameter toggle function onUseCharacterParamInput(event) { const value = Boolean($(event.target).prop("checked")); extension_settings[extensionName].use_character_param = value; saveSettingsDebounced(); // Re-register the function tool with updated schema when the setting is toggled registerFunctionTool(); } // Test function to visualize a bubble function onTestButtonClick() { const testText = "This is a test chat bubble generated by SillyBubble!"; const useCharParam = extension_settings[extensionName].use_character_param; const character = useCharParam ? extension_settings[extensionName].default_character : null; const bubbleType = extension_settings[extensionName].default_bubble_type; const markdown = generateChatBubbleImage(testText, null, character, bubbleType); // Determine what parameters to display in the test popup let paramInfo = `

Using bubble type: ${bubbleType}

`; if (useCharParam) { paramInfo = `

Using character: ${character}, bubble type: ${bubbleType}

`; } const testPopup = `

Generated Markdown:

${markdown.replace(//g, '>')}
${paramInfo}

Preview (if connected to image service):

${markdown}
`; callPopup(testPopup, 'text'); } // Register function tool function registerFunctionTool() { try { // Add function directly to context const context = getContext(); if (context) { context.generateChatBubbleImage = generateChatBubbleImage; console.log(`[${extensionName}] Function added to context: generateChatBubbleImage`); // Check if registerFunctionTool exists in context if (typeof context.registerFunctionTool === 'function') { // Create properties object for schema const properties = { text: { type: 'string', description: 'The text to display in the chat bubble' }, bubble_type: { type: 'string', description: 'The type of bubble to use (speech, thought). Defaults to speech.' } }; // Only add character parameter if enabled in settings if (extension_settings[extensionName].use_character_param) { properties.character = { type: 'string', description: 'The character to display (Example, Bianca, etc.). Each character has its own bubble style.' }; } // Always include style parameter for backward compatibility properties.style = { type: 'string', description: 'Legacy parameter: The visual style of the chat bubble (default, modern, retro, minimal).' }; // Define required parameters const requiredParams = ['text']; // Add character to required parameters if enabled if (extension_settings[extensionName].use_character_param) { requiredParams.push('character'); } // Define parameter schema following JSON schema format const bubbleSchema = Object.freeze({ $schema: 'http://json-schema.org/draft-04/schema#', type: 'object', properties: properties, required: requiredParams }); // Register the function tool using SillyTavern's own API context.registerFunctionTool({ name: 'generateChatBubbleImage', displayName: 'Chat Bubble Image', description: 'Creates a markdown image link with the text displayed as a styled chat bubble. Use when you want to visually style messages or display text in a speech or thought bubble.', parameters: bubbleSchema, action: async (args) => { if (!args?.text) return ''; let character; if (extension_settings[extensionName].use_character_param) { // When the setting is enabled, character is required // If not provided, use default character = args.character || extension_settings[extensionName].default_character; } else { // When the setting is disabled, always use the default character character = extension_settings[extensionName].default_character; } return generateChatBubbleImage(args.text, args.style, character, args.bubble_type); }, formatMessage: () => '', }); console.log(`[${extensionName}] Function tool registered via context.registerFunctionTool`); } else { console.warn(`[${extensionName}] registerFunctionTool not available in context`); } } else { console.warn(`[${extensionName}] Unable to get context for function registration`); } } catch (error) { console.error(`[${extensionName}] Error registering function tool:`, error); } } // Process tool call messages to render chat bubbles directly function processToolCallMessages() { if (!extension_settings[extensionName].render_in_collapse) { // Remove any previously rendered images if the feature is disabled $('.sillybubble-rendered-image').remove(); $('.smallSysMes.toolCall').removeAttr('data-sb-processed'); return; } // Look for tool call messages $('.mes.smallSysMes.toolCall').each(function() { // Check if this message has already been processed if ($(this).attr('data-sb-processed') === 'true') { return; // Skip processed messages } try { // Find the details element and its summary const detailsElement = $(this).find('details'); const summaryElement = $(this).find('summary'); if (detailsElement.length && summaryElement.length) { // Find the JSON content within the pre/code block const codeElement = $(this).find('pre code'); if (codeElement.length) { // Try to parse the JSON content const jsonText = codeElement.text(); try { const toolData = JSON.parse(jsonText); // Check if it's an array if (Array.isArray(toolData)) { // Process each tool call for (const tool of toolData) { // Check if this is our tool and it has a result if (tool.name === 'generateChatBubbleImage' && tool.result) { // Create a container for the rendered markdown const renderContainer = $('
'); // Convert markdown to actual HTML img tag const markdownImgRegex = /!\[\]\(([^)]+)\)/; const match = tool.result.match(markdownImgRegex); if (match && match[1]) { // Ensure the image URL is absolute let imgUrl = match[1]; if (!imgUrl.startsWith('http://') && !imgUrl.startsWith('https://')) { console.warn(`[${extensionName}] Image URL is not absolute: ${imgUrl}`); imgUrl = `http://${imgUrl}`; } console.log(`[${extensionName}] Rendering image from URL: ${imgUrl}`); renderContainer.html(`Chat Bubble`); // Store the image URL in a data attribute for easier access $(this).attr('data-sb-image-url', imgUrl); } else { renderContainer.html(tool.result); } // Clean up all images for this message $(this).find('.sillybubble-rendered-image').remove(); $(this).find('.sillybubble-collapsed-image').remove(); // Create a special always-visible container for collapsed state const collapsedContainer = $('
'); collapsedContainer.html(renderContainer.html()); // Get the message text element (where tool calls are shown) const messageTextDiv = $(this).find('.mes_text'); // Check if the details element is part of mes_text (most common case) const detailsInText = messageTextDiv.find('details').length > 0; if (detailsInText) { // For typical tool calls, add collapsed image just before the details element in mes_text messageTextDiv.find('details').before(collapsedContainer); } else { // Fallback - add it before the details element wherever it is detailsElement.before(collapsedContainer); } // ONLY add image in the reasoning div, NOT in the details summary const reasoningDiv = $(this).find('.mes_reasoning'); if (reasoningDiv.length) { // Make sure no existing image is there reasoningDiv.find('.sillybubble-rendered-image').remove(); // Add the image to the reasoning section reasoningDiv.prepend(renderContainer); } // Mark this message as processed $(this).attr('data-sb-processed', 'true'); // Manage visibility based on details open state handleDetailsVisibility($(this), detailsElement); console.log(`[${extensionName}] Rendered chat bubble image in tool call: ${imgUrl}`); } } } } catch (e) { console.warn(`[${extensionName}] Failed to parse JSON in tool call:`, e); } } } } catch (error) { console.error(`[${extensionName}] Error processing tool call message:`, error); } }); } // Helper function to handle visibility of images based on details open state function handleDetailsVisibility(messageElement, detailsElement) { // Add a class to the details element to mark it as containing a bubble image detailsElement.addClass('has-bubble-image'); // Set up a click handler for the details/summary to ensure proper rendering detailsElement.on('click', function() { // Small delay to ensure the open state has changed setTimeout(() => { // Use the CSS-based visibility approach defined in our styles // This is more reliable than show/hide in the DOM if (detailsElement.prop('open')) { messageElement.addClass('details-open'); } else { messageElement.removeClass('details-open'); } }, 10); }); // Set initial state if (detailsElement.prop('open')) { messageElement.addClass('details-open'); } else { messageElement.removeClass('details-open'); } } // Observer function to watch for new messages function setupMessageObserver() { // Create a mutation observer to monitor the chat for new messages const chatObserver = new MutationObserver((mutations) => { let shouldProcess = false; // Check for relevant mutations (new messages added) for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // Look for new message elements for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the node is a message or contains messages if (node.classList?.contains('mes') || node.querySelector?.('.mes')) { shouldProcess = true; break; } } } } if (shouldProcess) break; } // Process tool call messages if needed if (shouldProcess) { // Small delay to ensure the DOM is fully updated setTimeout(() => processToolCallMessages(), 100); } }); // Start observing the chat container const chatElement = document.getElementById('chat'); if (chatElement) { chatObserver.observe(chatElement, { childList: true, subtree: true }); console.log(`[${extensionName}] Message observer started`); // Process any existing messages setTimeout(() => processToolCallMessages(), 500); // Add additional event listener for details element clicks $(document).on('click', '.mes.smallSysMes.toolCall details, .mes.smallSysMes.toolCall summary', function() { // Force reprocessing of this specific tool call message after a short delay // This ensures the image is visible regardless of details open/closed state setTimeout(() => { const toolCallMessage = $(this).closest('.mes.smallSysMes.toolCall'); if (toolCallMessage.length) { // Remove the processed flag to force reprocessing toolCallMessage.removeAttr('data-sb-processed'); processToolCallMessages(); } }, 50); }); } else { console.warn(`[${extensionName}] Chat element not found, observer not started`); // Try again after a delay setTimeout(() => setupMessageObserver(), 2000); } } // Initialize extension jQuery(async () => { console.log(`[${extensionName}] Initializing...`); try { // Load settings HTML const settingsHtml = await $.get(`${extensionFolderPath}/example.html`); // Visual extensions go in the right column (extensions_settings2) $("#extensions_settings2").append(settingsHtml); // Register event listeners $("#sillybubble_enabled").on("input", onEnabledInput); $("#sillybubble_image_url").on("input", onImageUrlInput); $("#sillybubble_default_style").on("input", onDefaultStyleInput); $("#sillybubble_default_character").on("input", onDefaultCharacterInput); $("#sillybubble_default_bubble_type").on("input", onDefaultBubbleTypeInput); $("#sillybubble_render_in_collapse").on("input", onRenderInCollapseInput); $("#sillybubble_use_character_param").on("input", onUseCharacterParamInput); $("#sillybubble_test_button").on("click", onTestButtonClick); // Initial attempt to register the function tool registerFunctionTool(); // Make function globally accessible as fallback window.generateChatBubbleImage = generateChatBubbleImage; // Add CSS for rendered images $('head').append(` `); // Wait for SillyTavern to fully initialize $(document).ready(() => { // Try registering again after a short delay setTimeout(() => registerFunctionTool(), 2000); // Setup the message observer setupMessageObserver(); // Final attempt after SillyTavern is fully loaded setTimeout(() => { registerFunctionTool(); // Process any messages that might have been missed processToolCallMessages(); }, 5000); }); // Load settings loadSettings(); console.log(`[${extensionName}] Initialization complete`); } catch (error) { console.error(`[${extensionName}] Initialization error:`, error); } });