feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
226
dexto/packages/webui/components/ui/speech-controller.ts
Normal file
226
dexto/packages/webui/components/ui/speech-controller.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class SpeechController {
|
||||
private _speaking = false;
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private utterance: SpeechSynthesisUtterance | null = null;
|
||||
private voices: SpeechSynthesisVoice[] = [];
|
||||
private voiceSubscribers = new Set<Subscriber>();
|
||||
private preferredVoiceName: string | null = null;
|
||||
|
||||
get supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'speechSynthesis' in window &&
|
||||
'SpeechSynthesisUtterance' in window
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.supported) {
|
||||
try {
|
||||
const v = window.localStorage.getItem('ttsVoiceName');
|
||||
this.preferredVoiceName = v && v.length ? v : null;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
const populate = () => {
|
||||
try {
|
||||
const list = window.speechSynthesis.getVoices() || [];
|
||||
const changed =
|
||||
list.length !== this.voices.length ||
|
||||
list.some((v, i) => v.name !== this.voices[i]?.name);
|
||||
if (changed) {
|
||||
this.voices = list;
|
||||
this.voiceSubscribers.forEach((cb) => cb());
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
populate();
|
||||
try {
|
||||
// Cast to any to avoid referencing DOM EventListener identifier at runtime
|
||||
window.speechSynthesis.addEventListener('voiceschanged', populate as any);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSpeaking() {
|
||||
return this._speaking;
|
||||
}
|
||||
|
||||
subscribe(cb: Subscriber) {
|
||||
this.subscribers.add(cb);
|
||||
return () => {
|
||||
this.subscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
subscribeVoices(cb: Subscriber) {
|
||||
this.voiceSubscribers.add(cb);
|
||||
return () => {
|
||||
this.voiceSubscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
/* noop: subscriber error */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.supported) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
this.utterance = null;
|
||||
if (this._speaking) {
|
||||
this._speaking = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
speak(text: string, opts?: { rate?: number; pitch?: number; lang?: string }) {
|
||||
if (!this.supported) return;
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
this.stop();
|
||||
|
||||
const utter = new window.SpeechSynthesisUtterance(text);
|
||||
if (opts?.rate != null) utter.rate = opts.rate;
|
||||
if (opts?.pitch != null) utter.pitch = opts.pitch;
|
||||
const voice = this.resolveVoice();
|
||||
if (voice) {
|
||||
try {
|
||||
utter.voice = voice;
|
||||
if (opts?.lang) utter.lang = opts.lang;
|
||||
else if (voice.lang) utter.lang = voice.lang;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
} else if (opts?.lang) {
|
||||
utter.lang = opts.lang;
|
||||
}
|
||||
|
||||
utter.onstart = () => {
|
||||
if (this.utterance !== utter) return;
|
||||
this._speaking = true;
|
||||
this.notify();
|
||||
};
|
||||
const end = () => {
|
||||
if (this.utterance !== utter) return; // ignore events from stale utterances
|
||||
this._speaking = false;
|
||||
this.utterance = null;
|
||||
this.notify();
|
||||
};
|
||||
utter.onend = end;
|
||||
utter.onerror = end;
|
||||
|
||||
try {
|
||||
// mark current first to correlate with handlers
|
||||
this.utterance = utter;
|
||||
window.speechSynthesis.speak(utter);
|
||||
if (!this._speaking) {
|
||||
this._speaking = true;
|
||||
this.notify();
|
||||
}
|
||||
} catch {
|
||||
// ensure state resets if speaking fails
|
||||
end();
|
||||
}
|
||||
}
|
||||
|
||||
getVoices() {
|
||||
return this.voices;
|
||||
}
|
||||
setPreferredVoice(name: string | null) {
|
||||
this.preferredVoiceName = name && name.length ? name : null;
|
||||
try {
|
||||
if (this.preferredVoiceName)
|
||||
window.localStorage.setItem('ttsVoiceName', this.preferredVoiceName);
|
||||
else window.localStorage.removeItem('ttsVoiceName');
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
this.voiceSubscribers.forEach((cb) => cb());
|
||||
}
|
||||
getPreferredVoiceName() {
|
||||
return this.preferredVoiceName;
|
||||
}
|
||||
private resolveVoice(): SpeechSynthesisVoice | null {
|
||||
if (!this.voices.length) return null;
|
||||
const byName = this.preferredVoiceName
|
||||
? this.voices.find((v) => v.name === this.preferredVoiceName)
|
||||
: null;
|
||||
if (byName) return byName;
|
||||
|
||||
// Prefer Google UK English Female when available (Chrome typically exposes this voice)
|
||||
const ukFemale = this.voices.find(
|
||||
(v) =>
|
||||
v.name.toLowerCase() === 'google uk english female' ||
|
||||
(v.name.toLowerCase().includes('google uk english female') &&
|
||||
v.lang?.toLowerCase().startsWith('en-gb'))
|
||||
);
|
||||
if (ukFemale) return ukFemale;
|
||||
const score = (v: SpeechSynthesisVoice) => {
|
||||
const n = v.name.toLowerCase();
|
||||
const lang = (v.lang ?? '').toLowerCase();
|
||||
let s = 0;
|
||||
if (lang === 'en' || lang.startsWith('en-')) s += 3;
|
||||
if (n.includes('google')) s += 5;
|
||||
if (n.includes('microsoft') || n.includes('azure')) s += 4;
|
||||
if (n.includes('natural') || n.includes('neural') || n.includes('premium')) s += 4;
|
||||
if (n.includes('siri')) s += 3;
|
||||
if (n.includes('female')) s += 1;
|
||||
return s;
|
||||
};
|
||||
const sorted = [...this.voices].sort((a, b) => score(b) - score(a));
|
||||
return sorted[0] || this.voices[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __speechController: SpeechController | undefined;
|
||||
}
|
||||
const g = globalThis as typeof globalThis & { __speechController?: SpeechController };
|
||||
export const speechController =
|
||||
g.__speechController ?? (g.__speechController = new SpeechController());
|
||||
|
||||
export function useSpeechVoices(): {
|
||||
voices: SpeechSynthesisVoice[];
|
||||
selected: string | null;
|
||||
setSelected: (name: string | null) => void;
|
||||
} {
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(speechController.getVoices());
|
||||
const [selected, setSelectedState] = useState<string | null>(
|
||||
speechController.getPreferredVoiceName()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = speechController.subscribeVoices(() => {
|
||||
setVoices(speechController.getVoices());
|
||||
setSelectedState(speechController.getPreferredVoiceName());
|
||||
});
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
const setSelected = (name: string | null) => {
|
||||
speechController.setPreferredVoice(name);
|
||||
setSelectedState(speechController.getPreferredVoiceName());
|
||||
};
|
||||
|
||||
return { voices, selected, setSelected };
|
||||
}
|
||||
Reference in New Issue
Block a user