- Add character to required parameters when setting is enabled - Update documentation to clarify character is required - Ensure handler logic provides default character if missing - Maintain backward compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
596 lines
23 KiB
JavaScript
596 lines
23 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: "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 ``;
|
|
}
|
|
|
|
// 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 = `<p>Using bubble type: <strong>${bubbleType}</strong></p>`;
|
|
if (useCharParam) {
|
|
paramInfo = `<p>Using character: <strong>${character}</strong>, bubble type: <strong>${bubbleType}</strong></p>`;
|
|
}
|
|
|
|
const testPopup = `
|
|
<div class="sillybubble-test">
|
|
<p>Generated Markdown:</p>
|
|
<pre>${markdown.replace(/</g, '<').replace(/>/g, '>')}</pre>
|
|
${paramInfo}
|
|
<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') {
|
|
// 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 = $('<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 = `http://${imgUrl}`;
|
|
}
|
|
console.log(`[${extensionName}] Rendering image from URL: ${imgUrl}`);
|
|
renderContainer.html(`<img src="${imgUrl}" alt="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 = $('<div class="sillybubble-collapsed-image"></div>');
|
|
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(`
|
|
<style>
|
|
/* Style for bubble images in expanded details */
|
|
.sillybubble-rendered-image {
|
|
padding: 10px;
|
|
background-color: rgba(0, 0, 0, 0.05);
|
|
border-radius: 5px;
|
|
margin: 5px 0;
|
|
text-align: center;
|
|
display: block !important;
|
|
visibility: visible !important;
|
|
}
|
|
|
|
.sillybubble-rendered-image img {
|
|
max-width: 100%;
|
|
border-radius: 3px;
|
|
display: inline-block !important;
|
|
visibility: visible !important;
|
|
}
|
|
|
|
/* Style for bubble images when details is collapsed */
|
|
.sillybubble-collapsed-image {
|
|
padding: 10px 5px;
|
|
background-color: rgba(0, 0, 0, 0.03);
|
|
border-radius: 5px;
|
|
margin: 5px 0;
|
|
text-align: center;
|
|
display: block !important;
|
|
visibility: visible !important;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.sillybubble-collapsed-image img {
|
|
max-width: 100%;
|
|
border-radius: 3px;
|
|
display: inline-block !important;
|
|
visibility: visible !important;
|
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Hide the collapsed image when details is open using classes */
|
|
.details-open .sillybubble-collapsed-image,
|
|
details[open] ~ .sillybubble-collapsed-image,
|
|
details[open] + .sillybubble-collapsed-image,
|
|
.mes_text details[open] ~ .sillybubble-collapsed-image,
|
|
.mes_text details[open] + .sillybubble-collapsed-image {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Hide the "Tool calls: Chat Bubble Image" summary text when collapsed */
|
|
.smallSysMes.toolCall[data-sb-processed="true"] summary {
|
|
font-size: 0.8em;
|
|
opacity: 0.6;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.smallSysMes.toolCall[data-sb-processed="true"] summary:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Ensure details content is visible */
|
|
.smallSysMes.toolCall details .mes_reasoning {
|
|
display: block !important;
|
|
}
|
|
|
|
/* Make the collapsed image look nicer */
|
|
.smallSysMes.toolCall[data-sb-processed="true"] {
|
|
position: relative;
|
|
}
|
|
</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);
|
|
}
|
|
}); |