Improve triggers

This commit is contained in:
2025-04-23 03:37:26 +02:00
parent e5dcbe223d
commit 79ec4ce6ef
4 changed files with 311 additions and 73 deletions

View File

@@ -348,43 +348,62 @@
try {
const isActiveProfile = get(activeProfileId) === profileId;
// Always add to output history for this profile
addToOutputHistory(text);
// Process triggers if available for this profile
let processedText = text;
let isGagged = false;
let triggerMatched = false;
if (triggerSystem) {
try {
triggerSystem.processTriggers(text);
const result = triggerSystem.processTriggers(text);
processedText = result.processed;
isGagged = result.gagged;
triggerMatched = result.matched;
console.log(`Trigger processing result - gagged: ${isGagged}, matched: ${triggerMatched}, modified: ${processedText !== text}`);
} catch (error) {
console.error(`Error processing triggers for ${profileId}:`, error);
}
}
// Handle text-to-speech
console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`);
if (accessibilityManager && $accessibilitySettings.textToSpeech) {
// Speak if this is active profile OR speakAllProfiles is enabled
if (isActiveProfile || $accessibilitySettings.speakAllProfiles) {
console.log(`TTS condition satisfied, will attempt to speak for ${profileId}`);
// Use a small timeout to avoid UI blocking
setTimeout(() => {
try {
// If not active profile, add profile name prefix for context
const speechText = isActiveProfile ? text : `From ${getProfileName(profileId)}: ${text}`;
console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : ''));
accessibilityManager.speak(speechText);
} catch (error) {
console.error('Error using text-to-speech:', error);
}
}, 10);
// Add to output history if not gagged
if (!isGagged) {
addToOutputHistory(processedText);
// Handle text-to-speech for processed text
console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`);
if (accessibilityManager && $accessibilitySettings.textToSpeech) {
// Speak if this is active profile OR speakAllProfiles is enabled
if (isActiveProfile || $accessibilitySettings.speakAllProfiles) {
console.log(`TTS condition satisfied, will attempt to speak for ${profileId}`);
// Use a small timeout to avoid UI blocking
setTimeout(() => {
try {
// If not active profile, add profile name prefix for context
const speechText = isActiveProfile ? processedText : `From ${getProfileName(profileId)}: ${processedText}`;
console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : ''));
accessibilityManager.speak(speechText);
} catch (error) {
console.error('Error using text-to-speech:', error);
}
}, 10);
} else {
console.log(`TTS skipped: profile ${profileId} is not active and speakAll is disabled`);
}
} else {
console.log(`TTS skipped: profile ${profileId} is not active and speakAll is disabled`);
console.log(`TTS not enabled for profile ${profileId} or accessibilityManager is null`);
}
} else {
console.log(`TTS not enabled for profile ${profileId} or accessibilityManager is null`);
console.log(`Text was gagged by trigger - not adding to output history: ${text.substring(0, 50)}...`);
}
dispatch('received', { text });
// Dispatch event with information about trigger processing
dispatch('received', {
originalText: text,
processedText: processedText,
gagged: isGagged,
triggerMatched: triggerMatched
});
} catch (error) {
console.error(`Error handling received data for profile ${profileId}:`, error);
}

View File

@@ -16,9 +16,12 @@
isRegex: false,
isEnabled: true,
soundFile: '',
soundVolume: 0.7, // Default sound volume
sendText: '',
highlightColor: '',
priority: 0
priority: 0,
replaceText: '', // New text replacement option
gag: false // Option to hide matched text
};
// Available sounds
@@ -135,6 +138,64 @@
</div>
{/if}
{#if localTrigger.soundFile}
<div class="form-group">
<label for="soundVolume">Sound Volume</label>
<div class="range-control">
<input
type="range"
id="soundVolume"
bind:value={localTrigger.soundVolume}
min="0"
max="1"
step="0.1"
/>
<span class="range-value">{Math.round(localTrigger.soundVolume * 100)}%</span>
</div>
<small>Trigger sound volume is multiplied by global volume setting</small>
</div>
{/if}
<div class="form-group">
<label for="textAction">Text Action</label>
<select
id="textAction"
on:change={(e) => {
const val = e.target.value;
if (val === 'gag') {
localTrigger.gag = true;
localTrigger.replaceText = '';
} else if (val === 'replace') {
localTrigger.gag = false;
localTrigger.replaceText = localTrigger.replaceText || '';
} else {
localTrigger.gag = false;
localTrigger.replaceText = '';
}
}}
value={localTrigger.gag ? 'gag' : (localTrigger.replaceText ? 'replace' : 'none')}
>
<option value="none">No change (show original text)</option>
<option value="replace">Replace text</option>
<option value="gag">Hide text (gag)</option>
</select>
</div>
{#if !localTrigger.gag && localTrigger.replaceText !== undefined && localTrigger.replaceText !== null}
<div class="form-group">
<label for="replaceText">Replacement Text</label>
<textarea
id="replaceText"
bind:value={localTrigger.replaceText}
rows="3"
placeholder="Text to replace the matched text with"
></textarea>
{#if localTrigger.isRegex}
<small>Use $1, $2, etc. to reference captured groups</small>
{/if}
</div>
{/if}
<div class="form-group">
<label for="sendText">Send Text</label>
<input type="text" id="sendText" bind:value={localTrigger.sendText} />
@@ -306,6 +367,21 @@
font-weight: bold;
}
.range-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.range-control input {
flex: 1;
}
.range-value {
min-width: 40px;
text-align: right;
}
.match-list {
margin-top: 10px;
padding: 10px;

View File

@@ -133,7 +133,8 @@ export class MudConnection extends EventEmitter {
// Binary data
this.handleIncomingData(new Uint8Array(event.data));
} else if (typeof event.data === 'string') {
// Text data
// Text data - let listeners process it directly
// TriggerSystem will handle gagging and replacing in the component
this.emit('received', event.data);
} else if (event.data instanceof Blob) {
// Blob data (sometimes WebSockets send this instead of ArrayBuffer)

View File

@@ -10,10 +10,13 @@ export interface Trigger {
isRegex: boolean;
isEnabled: boolean;
soundFile?: string;
soundVolume?: number; // Sound volume (0-1)
action?: string;
sendText?: string;
highlightColor?: string;
priority: number;
replaceText?: string; // New text to replace the matched text with
gag?: boolean; // If true, don't display the matched text at all
}
export class TriggerSystem extends EventEmitter {
@@ -91,9 +94,11 @@ export class TriggerSystem extends EventEmitter {
// Sort triggers by priority
this.triggers.sort((a, b) => b.priority - a.priority);
// Preload sound if specified
// Preload sound if specified (now uses async method but doesn't wait)
if (trigger.soundFile) {
this.loadSound(trigger.id, trigger.soundFile);
this.loadSound(trigger.id, trigger.soundFile).catch(error => {
console.error(`Failed to preload sound for trigger ${trigger.id}:`, error);
});
}
// Save triggers to storage
@@ -126,9 +131,18 @@ export class TriggerSystem extends EventEmitter {
/**
* Process text for triggers
* @returns Object with information about gagging and replacement
*/
public processTriggers(text: string): void {
// Process only enabled triggers
public processTriggers(text: string): {
processed: string; // Text after all replacements
gagged: boolean; // Whether the text should be completely hidden
matched: boolean; // Whether any triggers matched
} {
let processedText = text;
let isGagged = false;
let anyTriggerMatched = false;
// Process only enabled triggers in priority order
for (const trigger of this.triggers.filter(t => t.isEnabled)) {
let matched = false;
let matches: RegExpMatchArray | null = null;
@@ -136,81 +150,209 @@ export class TriggerSystem extends EventEmitter {
if (trigger.isRegex) {
try {
const regex = new RegExp(trigger.pattern, 'g');
matches = text.match(regex);
matched = matches !== null;
matches = processedText.match(regex);
matched = matches !== null && matches.length > 0;
} catch (error) {
console.error(`Invalid regex pattern in trigger ${trigger.name}:`, error);
}
} else {
// Simple text matching
matched = text.includes(trigger.pattern);
matched = processedText.includes(trigger.pattern);
}
if (matched) {
this.executeTrigger(trigger, text, matches);
anyTriggerMatched = true;
// Execute the trigger actions
this.executeTrigger(trigger, processedText, matches);
// Handle gagging - if any trigger gags, the whole line is gagged
if (trigger.gag) {
isGagged = true;
}
// Handle text replacement if not gagged
if (!isGagged && trigger.replaceText) {
if (trigger.isRegex) {
try {
let replacedText = trigger.replaceText;
// If we have regex matches, replace $1, $2, etc. with capture groups
if (matches && matches.length > 0) {
// For actual text replacement, we need to use the regex directly
const regex = new RegExp(trigger.pattern, 'g');
// Use replace function to handle capture groups dynamically
processedText = processedText.replace(regex, (match, ...groups) => {
// Create a copy of the replacement template
let result = trigger.replaceText || '';
// Replace $1, $2, etc. with the actual captured values
for (let i = 0; i < groups.length - 2; i++) { // -2 to skip lastIndex and input
const captureValue = groups[i] || '';
result = result.replace(new RegExp(`\\$${i + 1}`, 'g'), captureValue);
}
return result;
});
}
} catch (error) {
console.error(`Error replacing text with regex in trigger ${trigger.name}:`, error);
}
} else {
// Simple string replacement for non-regex triggers
processedText = processedText.replace(
new RegExp(this.escapeRegExp(trigger.pattern), 'g'),
trigger.replaceText
);
}
}
}
}
return {
processed: processedText,
gagged: isGagged,
matched: anyTriggerMatched
};
}
/**
* Execute a triggered action - simplified for stability
* Helper to escape special regex characters in strings
*/
private escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Execute a triggered action with support for advanced features
*/
private executeTrigger(trigger: Trigger, text: string, matches: RegExpMatchArray | null): void {
// Play sound if specified - with minimal options
if (trigger.soundFile && this.sounds.has(trigger.id)) {
try {
const sound = this.sounds.get(trigger.id);
// Get settings
const uiSettingsValue = get(uiSettings);
const globalVolume = uiSettingsValue.globalVolume || 0.7;
// Handle sound playback, loading on demand if needed
if (trigger.soundFile) {
// Try to get the existing sound, or load it if not already loaded
const soundPromise = this.sounds.has(trigger.id)
? Promise.resolve(this.sounds.get(trigger.id)!)
: this.loadSound(trigger.id, trigger.soundFile);
soundPromise.then(sound => {
if (sound) {
sound.volume(0.7); // Fixed volume for stability
// Calculate volume based on trigger-specific volume and global volume
const triggerVolume = trigger.soundVolume !== undefined ? trigger.soundVolume : 0.7;
const finalVolume = Math.min(triggerVolume * globalVolume, 1.0); // Cap at 1.0
console.log(`Playing sound with final volume: ${finalVolume} (trigger: ${triggerVolume} * global: ${globalVolume})`);
sound.volume(finalVolume);
sound.play();
}
} catch (error) {
console.error('Error playing sound:', error);
}
}).catch(error => {
console.error(`Error playing sound for trigger ${trigger.id}:`, error);
});
}
// Send text if specified
// Send text if specified - support for regex capture group substitution
if (trigger.sendText) {
this.emit('sendText', trigger.sendText);
let textToSend = trigger.sendText;
// If we have regex matches, replace $1, $2, etc. with capture groups
if (trigger.isRegex && matches && matches.length > 1) {
// matches[0] is the full match, captures start at index 1
for (let i = 1; i < matches.length; i++) {
const captureGroup = matches[i] || '';
textToSend = textToSend.replace(new RegExp(`\\$${i}`, 'g'), captureGroup);
}
}
this.emit('sendText', textToSend);
}
// Skip custom actions for stability
// Handle text replacement or gagging
if (trigger.gag) {
// If gagged, emit a special event to prevent displaying the text
this.emit('gagText', text);
} else if (trigger.replaceText) {
let replacedText = trigger.replaceText;
// Emit highlight event if color specified
// If we have regex matches, replace $1, $2, etc. with capture groups
if (trigger.isRegex && matches && matches.length > 1) {
for (let i = 1; i < matches.length; i++) {
const captureGroup = matches[i] || '';
replacedText = replacedText.replace(new RegExp(`\\$${i}`, 'g'), captureGroup);
}
}
// Emit the replaced text instead of the original
this.emit('replaceText', { original: text, replacement: replacedText });
}
// Handle highlight if specified
if (trigger.highlightColor) {
this.emit('highlight', text, trigger.pattern, trigger.highlightColor, trigger.isRegex);
}
// Emit basic trigger fired event
this.emit('triggerFired', trigger.id);
// Execute custom action if specified and in browser environment
if (trigger.action && typeof window !== 'undefined') {
try {
// Create a sandboxed function with limited scope
const actionFn = new Function('text', 'matches', trigger.action);
actionFn(text, matches);
} catch (error) {
console.error(`Error executing custom action for trigger ${trigger.id}:`, error);
}
}
}
/**
* Load a sound file for a trigger - simplified version
* Load a sound file for a trigger with better error handling and loading status
* @returns Promise that resolves with the sound object when loaded
*/
private loadSound(triggerId: string, soundFile: string): void {
try {
console.log(`Loading sound for trigger ${triggerId}: ${soundFile}`);
private loadSound(triggerId: string, soundFile: string): Promise<Howl> {
return new Promise((resolve, reject) => {
try {
console.log(`Loading sound for trigger ${triggerId}: ${soundFile}`);
// Build path based on whether it's a URL or local file
const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/')
? soundFile
: `/sounds/${soundFile}`;
// If we already have this sound loaded, return it immediately
if (this.sounds.has(triggerId)) {
console.log(`Sound for trigger ${triggerId} already loaded, reusing`);
resolve(this.sounds.get(triggerId)!);
return;
}
// Create minimal sound object
const sound = new Howl({ src: [soundPath] });
// Build path based on whether it's a URL or local file
const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/')
? soundFile
: `/sounds/${soundFile}`;
// Set only necessary handlers
sound.once('load', () => {
this.sounds.set(triggerId, sound);
});
// Create sound object with HTML5 audio to better support streaming
const sound = new Howl({
src: [soundPath],
html5: true // Better for streaming and prevents global audio lock
});
sound.once('loaderror', () => {
console.error(`Failed to load sound ${soundFile}`);
});
} catch (error) {
console.error('Error loading sound:', error);
}
// Set up handlers
sound.once('load', () => {
console.log(`Sound for trigger ${triggerId} loaded successfully`);
this.sounds.set(triggerId, sound);
resolve(sound);
});
sound.once('loaderror', (id, error) => {
console.error(`Failed to load sound ${soundFile}:`, error);
reject(new Error(`Failed to load sound: ${error || 'Unknown error'}`));
});
} catch (error) {
console.error('Error setting up sound:', error);
reject(error);
}
});
}
/**