Merge branch 'dev'
This commit is contained in:
commit
7ec7a56cf2
|
|
@ -1,5 +1,6 @@
|
||||||
import { FolkShape } from "./folk-shape";
|
import { FolkShape } from "./folk-shape";
|
||||||
import { css, html } from "./tags";
|
import { css, html } from "./tags";
|
||||||
|
import { SpeechDictation } from "./speech-dictation";
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
:host {
|
:host {
|
||||||
|
|
@ -123,6 +124,30 @@ const styles = css`
|
||||||
background: #ea580c;
|
background: #ea580c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mic-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn:hover {
|
||||||
|
border-color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.recording {
|
||||||
|
border-color: #ef4444;
|
||||||
|
animation: micPulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
.username-prompt {
|
.username-prompt {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -228,6 +253,7 @@ export class FolkChat extends FolkShape {
|
||||||
<div class="messages"></div>
|
<div class="messages"></div>
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<input type="text" class="message-input" placeholder="Type a message..." />
|
<input type="text" class="message-input" placeholder="Type a message..." />
|
||||||
|
${SpeechDictation.isSupported() ? '<button class="mic-btn" title="Voice dictation">🎤</button>' : ''}
|
||||||
<button class="send-btn">Send</button>
|
<button class="send-btn">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -295,6 +321,41 @@ export class FolkChat extends FolkShape {
|
||||||
if (e.key === "Enter") sendMessage();
|
if (e.key === "Enter") sendMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Voice dictation
|
||||||
|
const micBtn = wrapper.querySelector(".mic-btn") as HTMLButtonElement | null;
|
||||||
|
if (micBtn) {
|
||||||
|
let baseText = "";
|
||||||
|
let interimText = "";
|
||||||
|
const dictation = new SpeechDictation({
|
||||||
|
onInterim: (text) => {
|
||||||
|
interimText = text;
|
||||||
|
messageInput.value = baseText + (baseText ? " " : "") + text;
|
||||||
|
},
|
||||||
|
onFinal: (text) => {
|
||||||
|
interimText = "";
|
||||||
|
baseText += (baseText ? " " : "") + text;
|
||||||
|
messageInput.value = baseText;
|
||||||
|
},
|
||||||
|
onStateChange: (recording) => {
|
||||||
|
micBtn.classList.toggle("recording", recording);
|
||||||
|
if (!recording) {
|
||||||
|
baseText = messageInput.value;
|
||||||
|
interimText = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => console.warn("Chat dictation:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!dictation.isRecording) {
|
||||||
|
baseText = messageInput.value;
|
||||||
|
}
|
||||||
|
dictation.toggle();
|
||||||
|
messageInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
closeBtn.addEventListener("click", (e) => {
|
closeBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FolkShape } from "./folk-shape";
|
import { FolkShape } from "./folk-shape";
|
||||||
import { css, html } from "./tags";
|
import { css, html } from "./tags";
|
||||||
|
import { SpeechDictation } from "./speech-dictation";
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
:host {
|
:host {
|
||||||
|
|
@ -48,6 +49,15 @@ const styles = css`
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions button.mic-recording {
|
||||||
|
animation: micPulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
height: calc(100% - 36px);
|
height: calc(100% - 36px);
|
||||||
|
|
@ -164,6 +174,7 @@ export class FolkMarkdown extends FolkShape {
|
||||||
</span>
|
</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="edit-btn" title="Toggle Edit">✏️</button>
|
<button class="edit-btn" title="Toggle Edit">✏️</button>
|
||||||
|
${SpeechDictation.isSupported() ? '<button class="mic-btn" title="Voice dictation">🎤</button>' : ''}
|
||||||
<button class="close-btn" title="Close">×</button>
|
<button class="close-btn" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,6 +248,36 @@ export class FolkMarkdown extends FolkShape {
|
||||||
this.dispatchEvent(new CustomEvent("close"));
|
this.dispatchEvent(new CustomEvent("close"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Voice dictation
|
||||||
|
const micBtn = wrapper.querySelector(".mic-btn") as HTMLButtonElement | null;
|
||||||
|
if (micBtn) {
|
||||||
|
const dictation = new SpeechDictation({
|
||||||
|
onInterim: (text) => {
|
||||||
|
// Show interim in editor (will be replaced by final)
|
||||||
|
},
|
||||||
|
onFinal: (text) => {
|
||||||
|
enterMarkdownEdit();
|
||||||
|
const pos = editor.selectionStart;
|
||||||
|
const before = editor.value.slice(0, pos);
|
||||||
|
const after = editor.value.slice(pos);
|
||||||
|
const sep = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
||||||
|
editor.value = before + sep + text + after;
|
||||||
|
this.#content = editor.value;
|
||||||
|
editor.selectionStart = editor.selectionEnd = pos + sep.length + text.length;
|
||||||
|
},
|
||||||
|
onStateChange: (recording) => {
|
||||||
|
micBtn.classList.toggle("mic-recording", recording);
|
||||||
|
if (recording) enterMarkdownEdit();
|
||||||
|
},
|
||||||
|
onError: (err) => console.warn("Markdown dictation:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dictation.toggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Editor input
|
// Editor input
|
||||||
editor.addEventListener("input", () => {
|
editor.addEventListener("input", () => {
|
||||||
this.#content = editor.value;
|
this.#content = editor.value;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FolkShape } from "./folk-shape";
|
import { FolkShape } from "./folk-shape";
|
||||||
import { css, html } from "./tags";
|
import { css, html } from "./tags";
|
||||||
|
import { SpeechDictation } from "./speech-dictation";
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
:host {
|
:host {
|
||||||
|
|
@ -175,6 +176,30 @@ const styles = css`
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mic-btn {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.recording {
|
||||||
|
border-color: #ef4444;
|
||||||
|
animation: micPulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
@ -293,6 +318,7 @@ export class FolkPrompt extends FolkShape {
|
||||||
</select>
|
</select>
|
||||||
<div class="prompt-row">
|
<div class="prompt-row">
|
||||||
<textarea class="prompt-input" placeholder="Type your message..." rows="2"></textarea>
|
<textarea class="prompt-input" placeholder="Type your message..." rows="2"></textarea>
|
||||||
|
${SpeechDictation.isSupported() ? '<button class="mic-btn" title="Voice dictation">🎤</button>' : ''}
|
||||||
<button class="send-btn">→</button>
|
<button class="send-btn">→</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -341,6 +367,45 @@ export class FolkPrompt extends FolkShape {
|
||||||
// Prevent drag on inputs
|
// Prevent drag on inputs
|
||||||
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
// Voice dictation
|
||||||
|
const micBtn = wrapper.querySelector(".mic-btn") as HTMLButtonElement | null;
|
||||||
|
if (micBtn) {
|
||||||
|
let baseText = "";
|
||||||
|
let interimText = "";
|
||||||
|
const dictation = new SpeechDictation({
|
||||||
|
onInterim: (text) => {
|
||||||
|
interimText = text;
|
||||||
|
if (this.#promptInput) {
|
||||||
|
this.#promptInput.value = baseText + (baseText ? " " : "") + text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinal: (text) => {
|
||||||
|
interimText = "";
|
||||||
|
baseText += (baseText ? " " : "") + text;
|
||||||
|
if (this.#promptInput) {
|
||||||
|
this.#promptInput.value = baseText;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStateChange: (recording) => {
|
||||||
|
micBtn.classList.toggle("recording", recording);
|
||||||
|
if (!recording) {
|
||||||
|
baseText = this.#promptInput?.value || "";
|
||||||
|
interimText = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => console.warn("Prompt dictation:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!dictation.isRecording) {
|
||||||
|
baseText = this.#promptInput?.value || "";
|
||||||
|
}
|
||||||
|
dictation.toggle();
|
||||||
|
this.#promptInput?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
closeBtn.addEventListener("click", (e) => {
|
closeBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,6 @@
|
||||||
import { FolkShape } from "./folk-shape";
|
import { FolkShape } from "./folk-shape";
|
||||||
import { css, html } from "./tags";
|
import { css, html } from "./tags";
|
||||||
|
import { SpeechDictation } from "./speech-dictation";
|
||||||
// Web Speech API types (not all browsers have these in their types)
|
|
||||||
interface SpeechRecognitionResult {
|
|
||||||
readonly length: number;
|
|
||||||
item(index: number): SpeechRecognitionAlternative;
|
|
||||||
[index: number]: SpeechRecognitionAlternative;
|
|
||||||
readonly isFinal: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionAlternative {
|
|
||||||
readonly transcript: string;
|
|
||||||
readonly confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionResultList {
|
|
||||||
readonly length: number;
|
|
||||||
item(index: number): SpeechRecognitionResult;
|
|
||||||
[index: number]: SpeechRecognitionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionEvent extends Event {
|
|
||||||
readonly resultIndex: number;
|
|
||||||
readonly results: SpeechRecognitionResultList;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionErrorEvent extends Event {
|
|
||||||
readonly error: string;
|
|
||||||
readonly message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognition extends EventTarget {
|
|
||||||
continuous: boolean;
|
|
||||||
interimResults: boolean;
|
|
||||||
lang: string;
|
|
||||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
|
||||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
|
||||||
onend: (() => void) | null;
|
|
||||||
start(): void;
|
|
||||||
stop(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionConstructor {
|
|
||||||
new (): SpeechRecognition;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
|
||||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
:host {
|
:host {
|
||||||
|
|
@ -292,7 +242,7 @@ export class FolkTranscription extends FolkShape {
|
||||||
#isRecording = false;
|
#isRecording = false;
|
||||||
#duration = 0;
|
#duration = 0;
|
||||||
#durationInterval: ReturnType<typeof setInterval> | null = null;
|
#durationInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
#recognition: SpeechRecognition | null = null;
|
#dictation: SpeechDictation | null = null;
|
||||||
#error: string | null = null;
|
#error: string | null = null;
|
||||||
|
|
||||||
#recordBtn: HTMLElement | null = null;
|
#recordBtn: HTMLElement | null = null;
|
||||||
|
|
@ -389,144 +339,95 @@ export class FolkTranscription extends FolkShape {
|
||||||
this.dispatchEvent(new CustomEvent("close"));
|
this.dispatchEvent(new CustomEvent("close"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize speech recognition
|
// Initialize speech dictation
|
||||||
this.#initSpeechRecognition();
|
this.#initDictation();
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
#initSpeechRecognition() {
|
#initDictation() {
|
||||||
const SpeechRecognitionImpl = window.SpeechRecognition || window.webkitSpeechRecognition;
|
if (!SpeechDictation.isSupported()) {
|
||||||
|
|
||||||
if (!SpeechRecognitionImpl) {
|
|
||||||
this.#error = "Speech recognition not supported in this browser";
|
this.#error = "Speech recognition not supported in this browser";
|
||||||
this.#renderError();
|
this.#renderError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#recognition = new SpeechRecognitionImpl();
|
this.#dictation = new SpeechDictation({
|
||||||
this.#recognition.continuous = true;
|
onInterim: (text) => {
|
||||||
this.#recognition.interimResults = true;
|
const interimIdx = this.#segments.findIndex((s) => !s.isFinal);
|
||||||
this.#recognition.lang = "en-US";
|
if (interimIdx >= 0) {
|
||||||
|
this.#segments[interimIdx].text = text;
|
||||||
this.#recognition.onresult = (event) => {
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
||||||
const result = event.results[i];
|
|
||||||
const text = result[0].transcript;
|
|
||||||
|
|
||||||
if (result.isFinal) {
|
|
||||||
// Find and update interim segment or add new
|
|
||||||
const interimIdx = this.#segments.findIndex((s) => !s.isFinal);
|
|
||||||
if (interimIdx >= 0) {
|
|
||||||
this.#segments[interimIdx] = {
|
|
||||||
...this.#segments[interimIdx],
|
|
||||||
text,
|
|
||||||
isFinal: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.#segments.push({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
text,
|
|
||||||
timestamp: this.#duration,
|
|
||||||
isFinal: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Update or add interim
|
this.#segments.push({
|
||||||
const interimIdx = this.#segments.findIndex((s) => !s.isFinal);
|
id: crypto.randomUUID(),
|
||||||
if (interimIdx >= 0) {
|
text,
|
||||||
this.#segments[interimIdx].text = text;
|
timestamp: this.#duration,
|
||||||
} else {
|
isFinal: false,
|
||||||
this.#segments.push({
|
});
|
||||||
id: crypto.randomUUID(),
|
|
||||||
text,
|
|
||||||
timestamp: this.#duration,
|
|
||||||
isFinal: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
this.#renderTranscript();
|
||||||
this.#renderTranscript();
|
},
|
||||||
};
|
onFinal: (text) => {
|
||||||
|
const interimIdx = this.#segments.findIndex((s) => !s.isFinal);
|
||||||
this.#recognition.onerror = (event) => {
|
if (interimIdx >= 0) {
|
||||||
console.error("Speech recognition error:", event.error);
|
this.#segments[interimIdx] = {
|
||||||
if (event.error !== "no-speech") {
|
...this.#segments[interimIdx],
|
||||||
this.#error = `Recognition error: ${event.error}`;
|
text,
|
||||||
|
isFinal: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.#segments.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text,
|
||||||
|
timestamp: this.#duration,
|
||||||
|
isFinal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.#renderTranscript();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("Speech recognition error:", err);
|
||||||
|
this.#error = err;
|
||||||
this.#renderError();
|
this.#renderError();
|
||||||
}
|
},
|
||||||
};
|
onStateChange: (recording) => {
|
||||||
|
this.#isRecording = recording;
|
||||||
this.#recognition.onend = () => {
|
if (recording) {
|
||||||
// Restart if still supposed to be recording
|
this.#error = null;
|
||||||
if (this.#isRecording && this.#recognition) {
|
this.#recordBtn?.classList.add("recording");
|
||||||
this.#recognition.start();
|
if (this.#statusEl) {
|
||||||
}
|
this.#statusEl.textContent = "Recording...";
|
||||||
};
|
this.#statusEl.classList.add("recording");
|
||||||
|
}
|
||||||
|
this.#durationInterval = setInterval(() => {
|
||||||
|
this.#duration++;
|
||||||
|
this.#updateDuration();
|
||||||
|
}, 1000);
|
||||||
|
this.dispatchEvent(new CustomEvent("recording-start"));
|
||||||
|
} else {
|
||||||
|
this.#recordBtn?.classList.remove("recording");
|
||||||
|
if (this.#statusEl) {
|
||||||
|
this.#statusEl.textContent = "Stopped";
|
||||||
|
this.#statusEl.classList.remove("recording");
|
||||||
|
}
|
||||||
|
if (this.#durationInterval) {
|
||||||
|
clearInterval(this.#durationInterval);
|
||||||
|
this.#durationInterval = null;
|
||||||
|
}
|
||||||
|
this.#segments = this.#segments.filter((s) => s.isFinal);
|
||||||
|
this.#renderTranscript();
|
||||||
|
this.dispatchEvent(new CustomEvent("recording-stop", { detail: { transcript: this.transcript } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleRecording() {
|
#toggleRecording() {
|
||||||
if (this.#isRecording) {
|
this.#dictation?.toggle();
|
||||||
this.#stopRecording();
|
|
||||||
} else {
|
|
||||||
this.#startRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#startRecording() {
|
|
||||||
if (!this.#recognition) {
|
|
||||||
this.#error = "Speech recognition not available";
|
|
||||||
this.#renderError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.#recognition.start();
|
|
||||||
this.#isRecording = true;
|
|
||||||
this.#error = null;
|
|
||||||
|
|
||||||
this.#recordBtn?.classList.add("recording");
|
|
||||||
if (this.#statusEl) {
|
|
||||||
this.#statusEl.textContent = "Recording...";
|
|
||||||
this.#statusEl.classList.add("recording");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start duration timer
|
|
||||||
this.#durationInterval = setInterval(() => {
|
|
||||||
this.#duration++;
|
|
||||||
this.#updateDuration();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("recording-start"));
|
|
||||||
} catch (error) {
|
|
||||||
this.#error = "Failed to start recording";
|
|
||||||
this.#renderError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#stopRecording() {
|
#stopRecording() {
|
||||||
if (!this.#isRecording) return;
|
this.#dictation?.stop();
|
||||||
|
|
||||||
this.#recognition?.stop();
|
|
||||||
this.#isRecording = false;
|
|
||||||
|
|
||||||
this.#recordBtn?.classList.remove("recording");
|
|
||||||
if (this.#statusEl) {
|
|
||||||
this.#statusEl.textContent = "Stopped";
|
|
||||||
this.#statusEl.classList.remove("recording");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop duration timer
|
|
||||||
if (this.#durationInterval) {
|
|
||||||
clearInterval(this.#durationInterval);
|
|
||||||
this.#durationInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any interim segments
|
|
||||||
this.#segments = this.#segments.filter((s) => s.isFinal);
|
|
||||||
this.#renderTranscript();
|
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("recording-stop", { detail: { transcript: this.transcript } }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateDuration() {
|
#updateDuration() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Web Speech API types (not all browsers have these in their types)
|
||||||
|
export interface SpeechRecognitionResult {
|
||||||
|
readonly length: number;
|
||||||
|
item(index: number): SpeechRecognitionAlternative;
|
||||||
|
[index: number]: SpeechRecognitionAlternative;
|
||||||
|
readonly isFinal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechRecognitionAlternative {
|
||||||
|
readonly transcript: string;
|
||||||
|
readonly confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechRecognitionResultList {
|
||||||
|
readonly length: number;
|
||||||
|
item(index: number): SpeechRecognitionResult;
|
||||||
|
[index: number]: SpeechRecognitionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechRecognitionEvent extends Event {
|
||||||
|
readonly resultIndex: number;
|
||||||
|
readonly results: SpeechRecognitionResultList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechRecognitionErrorEvent extends Event {
|
||||||
|
readonly error: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechRecognitionInstance extends EventTarget {
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
lang: string;
|
||||||
|
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||||
|
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionConstructor {
|
||||||
|
new (): SpeechRecognitionInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||||
|
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechDictationOptions {
|
||||||
|
onInterim?: (text: string) => void;
|
||||||
|
onFinal?: (text: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onStateChange?: (recording: boolean) => void;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpeechDictation {
|
||||||
|
#recognition: SpeechRecognitionInstance | null = null;
|
||||||
|
#recording = false;
|
||||||
|
#opts: SpeechDictationOptions;
|
||||||
|
|
||||||
|
constructor(opts: SpeechDictationOptions) {
|
||||||
|
this.#opts = opts;
|
||||||
|
this.#init();
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSupported(): boolean {
|
||||||
|
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRecording(): boolean {
|
||||||
|
return this.#recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.#recording || !this.#recognition) return;
|
||||||
|
try {
|
||||||
|
this.#recognition.start();
|
||||||
|
this.#recording = true;
|
||||||
|
this.#opts.onStateChange?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
this.#opts.onError?.("Failed to start recording");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.#recording) return;
|
||||||
|
this.#recording = false;
|
||||||
|
this.#recognition?.stop();
|
||||||
|
this.#opts.onStateChange?.(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
if (this.#recording) {
|
||||||
|
this.stop();
|
||||||
|
} else {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
if (this.#recognition) {
|
||||||
|
this.#recognition.onresult = null;
|
||||||
|
this.#recognition.onerror = null;
|
||||||
|
this.#recognition.onend = null;
|
||||||
|
this.#recognition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#init(): void {
|
||||||
|
const Impl = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!Impl) return;
|
||||||
|
|
||||||
|
this.#recognition = new Impl();
|
||||||
|
this.#recognition.continuous = true;
|
||||||
|
this.#recognition.interimResults = true;
|
||||||
|
this.#recognition.lang = this.#opts.lang ?? "en-US";
|
||||||
|
|
||||||
|
this.#recognition.onresult = (event) => {
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i];
|
||||||
|
const text = result[0].transcript;
|
||||||
|
if (result.isFinal) {
|
||||||
|
this.#opts.onFinal?.(text);
|
||||||
|
} else {
|
||||||
|
this.#opts.onInterim?.(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#recognition.onerror = (event) => {
|
||||||
|
if (event.error !== "no-speech") {
|
||||||
|
this.#opts.onError?.(`Recognition error: ${event.error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#recognition.onend = () => {
|
||||||
|
// Auto-restart while still recording
|
||||||
|
if (this.#recording && this.#recognition) {
|
||||||
|
this.#recognition.start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { getAccessToken } from "./rstack-identity";
|
||||||
import { parseMiActions, summariseActions } from "../../lib/mi-actions";
|
import { parseMiActions, summariseActions } from "../../lib/mi-actions";
|
||||||
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
||||||
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
||||||
|
import { SpeechDictation } from "../../lib/speech-dictation";
|
||||||
|
|
||||||
interface MiMessage {
|
interface MiMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
|
@ -22,6 +23,8 @@ export class RStackMi extends HTMLElement {
|
||||||
#shadow: ShadowRoot;
|
#shadow: ShadowRoot;
|
||||||
#messages: MiMessage[] = [];
|
#messages: MiMessage[] = [];
|
||||||
#abortController: AbortController | null = null;
|
#abortController: AbortController | null = null;
|
||||||
|
#dictation: SpeechDictation | null = null;
|
||||||
|
#interimText = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -40,6 +43,7 @@ export class RStackMi extends HTMLElement {
|
||||||
<span class="mi-icon">✧</span>
|
<span class="mi-icon">✧</span>
|
||||||
<input class="mi-input" id="mi-input" type="text"
|
<input class="mi-input" id="mi-input" type="text"
|
||||||
placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
|
placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
|
||||||
|
${SpeechDictation.isSupported() ? '<button class="mi-mic-btn" id="mi-mic" title="Voice dictation">🎤</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="mi-panel" id="mi-panel">
|
<div class="mi-panel" id="mi-panel">
|
||||||
<div class="mi-messages" id="mi-messages">
|
<div class="mi-messages" id="mi-messages">
|
||||||
|
|
@ -84,6 +88,40 @@ export class RStackMi extends HTMLElement {
|
||||||
// Stop clicks inside the panel from closing it
|
// Stop clicks inside the panel from closing it
|
||||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||||
bar.addEventListener("click", (e) => e.stopPropagation());
|
bar.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
// Voice dictation
|
||||||
|
const micBtn = this.#shadow.getElementById("mi-mic") as HTMLButtonElement | null;
|
||||||
|
if (micBtn) {
|
||||||
|
let baseText = "";
|
||||||
|
this.#dictation = new SpeechDictation({
|
||||||
|
onInterim: (text) => {
|
||||||
|
this.#interimText = text;
|
||||||
|
input.value = baseText + (baseText ? " " : "") + text;
|
||||||
|
},
|
||||||
|
onFinal: (text) => {
|
||||||
|
this.#interimText = "";
|
||||||
|
baseText += (baseText ? " " : "") + text;
|
||||||
|
input.value = baseText;
|
||||||
|
},
|
||||||
|
onStateChange: (recording) => {
|
||||||
|
micBtn.classList.toggle("recording", recording);
|
||||||
|
if (!recording) {
|
||||||
|
baseText = input.value;
|
||||||
|
this.#interimText = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => console.warn("MI dictation:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
micBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!this.#dictation!.isRecording) {
|
||||||
|
baseText = input.value;
|
||||||
|
}
|
||||||
|
this.#dictation!.toggle();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gather page context: open shapes, active module, tabs, canvas state. */
|
/** Gather page context: open shapes, active module, tabs, canvas state. */
|
||||||
|
|
@ -328,6 +366,21 @@ const STYLES = `
|
||||||
}
|
}
|
||||||
.mi-input::placeholder { color: var(--rs-text-muted); }
|
.mi-input::placeholder { color: var(--rs-text-muted); }
|
||||||
|
|
||||||
|
.mi-mic-btn {
|
||||||
|
background: none; border: none; cursor: pointer; padding: 2px 4px;
|
||||||
|
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
|
||||||
|
flex-shrink: 0; line-height: 1;
|
||||||
|
}
|
||||||
|
.mi-mic-btn:hover { background: var(--rs-bg-hover); }
|
||||||
|
.mi-mic-btn.recording {
|
||||||
|
animation: micPulse 1.5s infinite;
|
||||||
|
filter: saturate(2) brightness(1.1);
|
||||||
|
}
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
.mi-panel {
|
.mi-panel {
|
||||||
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
|
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
|
||||||
min-width: 360px; max-height: 420px;
|
min-width: 360px; max-height: 420px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue