} true if successful
*/
async #initMediaRecorder() {
@@ -891,22 +1060,87 @@ class Odidere {
this.mediaRecorder.addEventListener('stop', async () => {
if (this.audioChunks.length === 0) return;
+ // Capture the mode synchronously before any async work.
+ // The 'stop' event fires asynchronously, so this.isTranscribing
+ // may have changed by the time we await below.
+ const wasTranscribing = this.isTranscribing;
+
const audioBlob = new Blob(this.audioChunks, {
type: this.mediaRecorder.mimeType,
});
this.audioChunks = [];
- // Capture and clear text input to send with audio.
- const text = this.$textInput.value.trim();
- this.$textInput.value = '';
- this.$textInput.style.height = 'auto';
+ // Clear the flag now that the stop event has fired.
+ if (wasTranscribing) {
+ this.isTranscribing = false;
+ this.$transcribe.classList.remove('compose__action-btn--recording');
+ } else {
+ this.#setRecordingState(false);
+ }
- await this.#sendRequest({ audio: audioBlob, text });
+ // Transcribe the audio.
+ try {
+ const { text: transcription, detectedLanguage } =
+ await this.#transcribeAudio(audioBlob);
+
+ // Resolve the effective voice for this message.
+ // If the selector is "Auto", pick a default for the detected language.
+ // This does NOT change the dropdown - the voice is per-message only.
+ let voice = this.$voice.value;
+ if (!voice && detectedLanguage) {
+ voice = VOICE_MAP[detectedLanguage] || '';
+ }
+
+ // Branch: transcribe-only (populate textarea) vs PTT (send).
+ if (wasTranscribing) {
+ // Transcribe mode: populate textarea, do not send.
+ this.$transcribe.classList.add('compose__action-btn--transcribing');
+ try {
+ if (transcription) {
+ const existing = this.$textInput.value;
+ if (existing.trim()) {
+ this.$textInput.value = `${existing}\n${transcription}`;
+ } else {
+ this.$textInput.value = transcription;
+ }
+ this.#handleTextareaInput();
+ }
+ } finally {
+ this.$transcribe.classList.remove(
+ 'compose__action-btn--transcribing',
+ );
+ this.$textInput.focus();
+ }
+ } else {
+ // PTT mode: capture text, clear input, send request.
+ const text = this.$textInput.value.trim();
+ this.$textInput.value = '';
+ this.$textInput.style.height = 'auto';
+
+ const combined = text ? `${text}\n${transcription}` : transcription;
+ await this.#sendRequest({
+ text: combined,
+ detectedLanguage,
+ voice,
+ });
+ }
+ } catch (e) {
+ console.error('transcription failed:', e);
+ this.#renderError(`Transcription failed: ${e.message}`);
+ if (!wasTranscribing) {
+ this.#setLoadingState(false);
+ }
+ }
});
this.mediaRecorder.addEventListener('error', (event) => {
console.error('MediaRecorder error:', event.error);
- this.#setRecordingState(false);
+ if (this.isTranscribing) {
+ this.isTranscribing = false;
+ this.$transcribe.classList.remove('compose__action-btn--recording');
+ } else {
+ this.#setRecordingState(false);
+ }
this.audioChunks = [];
this.#renderError(
`Recording error: ${event.error?.message || 'Unknown error'}`,
@@ -1181,25 +1415,24 @@ class Odidere {
*
* For text-only input, renders the user message immediately for
* responsiveness.
- * For audio input, waits for the server's transcription event before
- * rendering the user message.
*
* Messages are rendered incrementally as SSE events arrive:
- * - user (transcription), assistant (tool calls), tool (results),
+ * - user, assistant (tool calls), tool (results),
* and a final assistant message with content.
*
* @param {Object} options
- * @param {Blob} [options.audio] - Recorded audio blob
* @param {string} [options.text] - Text input
+ * @param {string} [options.detectedLanguage] - Detected language from STT
+ * @param {string} [options.voice] - Resolved voice for this message (from auto-selection)
*/
- async #sendRequest({ audio = null, text = '' }) {
+ async #sendRequest({ text = '', detectedLanguage = '', voice }) {
this.#setLoadingState(true);
// Re-enable auto-scroll when the user sends a new message.
this.isAutoScrollEnabled = true;
this.currentController = new AbortController();
try {
- const hasNewInput = text || this.attachments.length > 0 || audio;
+ const hasNewInput = text || this.attachments.length > 0;
// Build messages array from active path + current user message.
const activePath = this.getActivePath();
@@ -1221,25 +1454,24 @@ class Odidere {
const payload = {
messages,
- voice: this.$voice.value,
+ voice: voice ?? this.$voice.value,
model: this.$model.value,
};
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
if (systemMessage) {
payload.system_message = systemMessage;
}
- if (audio) {
- payload.audio = await this.#toBase64(audio);
- }
// Clear attachments after building payload (before async operations).
this.#clearAttachments();
// For text-only requests with new input, add to history and render
// immediately. Skip when continuing conversation (no new user message).
- if (!audio && hasNewInput) {
- const appended = this.#appendHistory([userMessage]);
- this.#renderMessages(appended);
+ if (hasNewInput) {
+ const meta = {};
+ if (detectedLanguage) meta.language = detectedLanguage;
+ const appended = this.#appendHistory([userMessage], meta);
+ this.#renderMessages(appended, meta);
}
const res = await fetch(STREAM_ENDPOINT, {
@@ -1296,37 +1528,6 @@ class Odidere {
}
const message = event.message;
- // Handle user message from server (audio transcription).
- if (message.role === 'user' && event.transcription) {
- const displayParts = [];
- if (text) displayParts.push(text);
- if (event.transcription) displayParts.push(event.transcription);
- const displayContent = displayParts.join('\n\n');
-
- let historyContent;
- if (typeof userMessage.content === 'string') {
- historyContent = displayContent;
- } else {
- historyContent = [...userMessage.content];
- if (event.transcription) {
- historyContent.push({
- type: 'text',
- text: event.transcription,
- });
- }
- }
-
- const audioUserMessage = {
- id: userMessage.id,
- role: 'user',
- content: historyContent,
- meta: { language: event.detected_language },
- };
-
- this.#appendHistory([audioUserMessage]);
- this.#renderMessages([audioUserMessage]);
- continue;
- }
// Stash assistant messages with tool calls; render once all
// tool results have arrived so the output is visible in the UI.
@@ -1488,6 +1689,7 @@ class Odidere {
this.$chatLoading.hidden = !loading;
// Keep the send button enabled during loading so it can act as a stop button.
this.$ptt.disabled = loading;
+ this.$transcribe.disabled = loading;
// Clear any pending stop state when loading ends.
if (!loading && this.isStopPending) {
this.isStopPending = false;
diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml
index 8ff1e93..2e6a19e 100644
--- a/internal/service/templates/static/footer/compose.gohtml
+++ b/internal/service/templates/static/footer/compose.gohtml
@@ -19,6 +19,15 @@
+