SillyBubble/index.js
Sven Olderaan a907c4d073 Add HTML fallback and improve bubble rendering in collapsed tool calls
- Create image_html.php as fallback when GD not available
- Add test.html page to demonstrate different bubble styles
- Update index.js to support rendering bubbles in collapsed tool calls
- Improve example.html settings page with new options
- Add reference/ to .gitignore

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-16 12:49:14 +01:00

390 lines
14 KiB
JavaScript

// 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: "https://calista.the.sexiest.cat/image.php", // Use fully qualified URL by default
default_style: "default",
enabled: true,
render_in_collapse: true // Setting to enable/disable rendering in collapsed tool calls
};
// 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 https://`);
extension_settings[extensionName].image_service_url = `https://${extension_settings[extensionName].image_service_url}`;
}
// Function for AI to call - generates markdown image with URL-encoded text
function generateChatBubbleImage(text, style) {
if (!extension_settings[extensionName].enabled) {
return text;
}
// Use default style if not specified
const bubbleStyle = style || extension_settings[extensionName].default_style;
// 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 = `https://${serviceUrl}`;
}
// Construct the URL with the encoded text
const imageUrl = `${serviceUrl}?q=${encodedText}`;
// Add style parameter if specified
const fullUrl = bubbleStyle !== "default"
? `${imageUrl}&style=${bubbleStyle}`
: imageUrl;
console.log(`[${extensionName}] Generated image URL: ${fullUrl}`);
// Return markdown image format
return `![](${fullUrl})`;
}
// 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_render_in_collapse").prop("checked", extension_settings[extensionName].render_in_collapse).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 = `https://${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 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();
}
// Test function to visualize a bubble
function onTestButtonClick() {
const testText = "This is a test chat bubble generated by SillyBubble!";
const markdown = generateChatBubbleImage(testText);
const testPopup = `
<div class="sillybubble-test">
<p>Generated Markdown:</p>
<pre>${markdown.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
<p>Preview (if connected to image service):</p>
<div>${markdown}</div>
</div>
`;
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') {
// Define parameter schema following JSON schema format
const bubbleSchema = Object.freeze({
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: {
text: {
type: 'string',
description: 'The text to display in the chat bubble'
},
style: {
type: 'string',
description: 'The visual style of the chat bubble (default, modern, retro, minimal)'
},
},
required: ['text']
});
// 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 bubble.',
parameters: bubbleSchema,
action: async (args) => {
if (!args?.text) return '';
return generateChatBubbleImage(args.text, args.style);
},
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 = $('<div class="sillybubble-rendered-image"></div>');
// 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 = `https://${imgUrl}`;
}
console.log(`[${extensionName}] Rendering image from URL: ${imgUrl}`);
renderContainer.html(`<img src="${imgUrl}" alt="Chat Bubble">`);
} else {
renderContainer.html(tool.result);
}
// Remove any existing rendered images
$(this).find('.sillybubble-rendered-image').remove();
// Add this image after the summary element, but only if details is present
if (summaryElement.length) {
summaryElement.after(renderContainer);
}
// Mark this message as processed
$(this).attr('data-sb-processed', 'true');
console.log(`[${extensionName}] Rendered chat bubble image in tool call`);
}
}
}
} catch (e) {
console.warn(`[${extensionName}] Failed to parse JSON in tool call:`, e);
}
}
}
} catch (error) {
console.error(`[${extensionName}] Error processing tool call message:`, error);
}
});
}
// 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);
} 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_render_in_collapse").on("input", onRenderInCollapseInput);
$("#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(`
<style>
.sillybubble-rendered-image {
padding: 10px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 5px;
margin: 5px 0;
text-align: center;
}
.sillybubble-rendered-image img {
max-width: 100%;
border-radius: 3px;
}
.smallSysMes.toolCall[data-sb-processed="true"] summary {
cursor: pointer;
}
/* Make the text in the summary a bit less prominent when an image is shown */
.smallSysMes.toolCall[data-sb-processed="true"] summary {
opacity: 0.7;
}
.smallSysMes.toolCall[data-sb-processed="true"] summary:hover {
opacity: 1;
}
</style>
`);
// 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);
}
});