417 lines
13 KiB
JavaScript
417 lines
13 KiB
JavaScript
/** @odoo-module **/
|
|
// Fusion Notes - Chatter Voice Note Integration
|
|
// Copyright 2026 Nexa Systems Inc.
|
|
// License OPL-1
|
|
//
|
|
// Patches the Odoo 19 Chatter component to add a voice recording
|
|
// microphone button. Records audio via MediaRecorder, transcribes
|
|
// with OpenAI Whisper, optionally formats with GPT, and posts the
|
|
// result as a "Log note" in the chatter.
|
|
|
|
import { Chatter } from "@mail/chatter/web_portal/chatter";
|
|
import { patch } from "@web/core/utils/patch";
|
|
import { onWillUnmount } from "@odoo/owl";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
import {
|
|
AudioRecorder,
|
|
SpeechRecognizer,
|
|
blobToBase64,
|
|
} from "./voice_note_service";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings cache - avoids repeated RPC calls for user preferences
|
|
// ---------------------------------------------------------------------------
|
|
let _settingsCache = null;
|
|
let _settingsCacheTime = 0;
|
|
const SETTINGS_CACHE_TTL = 60000; // 1 minute
|
|
|
|
async function getSettings() {
|
|
const now = Date.now();
|
|
if (_settingsCache && now - _settingsCacheTime < SETTINGS_CACHE_TTL) {
|
|
return _settingsCache;
|
|
}
|
|
try {
|
|
const result = await rpc('/fusion_notes/get_settings');
|
|
_settingsCache = result;
|
|
_settingsCacheTime = now;
|
|
return result;
|
|
} catch {
|
|
return {
|
|
review_mode: true,
|
|
ai_format: false,
|
|
max_seconds: 300,
|
|
has_api_key: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Patch Chatter to add voice note capabilities
|
|
// ---------------------------------------------------------------------------
|
|
patch(Chatter.prototype, {
|
|
setup() {
|
|
super.setup(...arguments);
|
|
|
|
// Notification service for user feedback
|
|
this.notificationService = useService("notification");
|
|
|
|
// Voice note reactive state
|
|
Object.assign(this.state, {
|
|
// Status: idle | recording | transcribing | formatting | review
|
|
voiceStatus: 'idle',
|
|
voiceText: '', // Current display text
|
|
voiceRawText: '', // Original transcription
|
|
voiceFormattedText: '', // AI-formatted version (cached)
|
|
voiceAiFormat: false, // AI formatting toggle
|
|
voiceQuickPost: false, // Quick-post mode toggle
|
|
voiceDuration: 0, // Recording duration in seconds
|
|
voiceError: '', // Last error message
|
|
});
|
|
|
|
// Private instances (not reactive)
|
|
this._voiceRecorder = new AudioRecorder();
|
|
this._voiceSpeechRecognizer = null;
|
|
this._voiceTimer = null;
|
|
this._voiceUsingSpeechFallback = false;
|
|
this._voiceSpeechPromise = null;
|
|
|
|
// Cleanup on component destroy
|
|
onWillUnmount(() => {
|
|
this._voiceCleanup();
|
|
});
|
|
},
|
|
|
|
// ===================================================================
|
|
// Public methods (called from template)
|
|
// ===================================================================
|
|
|
|
/**
|
|
* Start recording audio from the microphone.
|
|
* Uses MediaRecorder if available, falls back to Web Speech API.
|
|
*/
|
|
async voiceStartRecording() {
|
|
// Close the normal composer if open
|
|
if (this.state.composerType) {
|
|
this.state.composerType = false;
|
|
}
|
|
|
|
// Reset voice state
|
|
Object.assign(this.state, {
|
|
voiceStatus: 'recording',
|
|
voiceText: '',
|
|
voiceRawText: '',
|
|
voiceFormattedText: '',
|
|
voiceError: '',
|
|
voiceDuration: 0,
|
|
voiceAiFormat: false,
|
|
});
|
|
|
|
// Load user preferences
|
|
const settings = await getSettings();
|
|
this.state.voiceQuickPost = !settings.review_mode;
|
|
this.state.voiceAiFormat = settings.ai_format;
|
|
|
|
try {
|
|
if (this._voiceRecorder.isSupported) {
|
|
// Primary: MediaRecorder + Whisper API
|
|
await this._voiceRecorder.start();
|
|
this._voiceUsingSpeechFallback = false;
|
|
} else {
|
|
// Fallback: Browser Speech Recognition
|
|
this._voiceSpeechRecognizer = new SpeechRecognizer();
|
|
if (!this._voiceSpeechRecognizer.isSupported) {
|
|
throw new Error(
|
|
'Neither audio recording nor speech recognition ' +
|
|
'is supported in this browser. Please use Chrome, ' +
|
|
'Edge, or Firefox.'
|
|
);
|
|
}
|
|
this._voiceSpeechPromise =
|
|
this._voiceSpeechRecognizer.start();
|
|
this._voiceUsingSpeechFallback = true;
|
|
}
|
|
|
|
// Start duration timer
|
|
this._voiceTimer = setInterval(() => {
|
|
this.state.voiceDuration++;
|
|
// Auto-stop at max duration
|
|
if (
|
|
settings.max_seconds &&
|
|
this.state.voiceDuration >= settings.max_seconds
|
|
) {
|
|
this.voiceStopRecording();
|
|
}
|
|
}, 1000);
|
|
} catch (error) {
|
|
this.state.voiceStatus = 'idle';
|
|
this.state.voiceError =
|
|
error.message || 'Failed to start recording.';
|
|
this._voiceNotify(this.state.voiceError, 'danger');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Stop recording and transcribe the audio.
|
|
*/
|
|
async voiceStopRecording() {
|
|
if (this.state.voiceStatus !== 'recording') {
|
|
return;
|
|
}
|
|
|
|
// Stop timer
|
|
clearInterval(this._voiceTimer);
|
|
this._voiceTimer = null;
|
|
|
|
this.state.voiceStatus = 'transcribing';
|
|
|
|
try {
|
|
let text = '';
|
|
|
|
if (this._voiceUsingSpeechFallback) {
|
|
// Speech Recognition fallback - text already accumulated
|
|
this._voiceSpeechRecognizer.stop();
|
|
text = await this._voiceSpeechPromise;
|
|
this._voiceSpeechRecognizer = null;
|
|
this._voiceSpeechPromise = null;
|
|
} else {
|
|
// MediaRecorder - send audio to Whisper API
|
|
const audioBlob = await this._voiceRecorder.stop();
|
|
const audioBase64 = await blobToBase64(audioBlob);
|
|
|
|
const result = await rpc('/fusion_notes/transcribe', {
|
|
audio_base64: audioBase64,
|
|
mime_type: this._voiceRecorder.baseMimeType,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(result.error);
|
|
}
|
|
text = result.text;
|
|
}
|
|
|
|
if (!text || !text.trim()) {
|
|
throw new Error('No speech detected. Please try again.');
|
|
}
|
|
|
|
this.state.voiceRawText = text.trim();
|
|
this.state.voiceText = this.state.voiceRawText;
|
|
|
|
// AI format if enabled by default
|
|
if (this.state.voiceAiFormat) {
|
|
await this._voiceFormatText();
|
|
}
|
|
|
|
// Quick post or show for review
|
|
if (this.state.voiceQuickPost) {
|
|
await this._voicePostNote();
|
|
} else {
|
|
this.state.voiceStatus = 'review';
|
|
}
|
|
} catch (error) {
|
|
this.state.voiceStatus = 'idle';
|
|
this.state.voiceError =
|
|
error.message || 'Transcription failed.';
|
|
this._voiceNotify(this.state.voiceError, 'danger');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Toggle AI formatting on the transcribed text.
|
|
*/
|
|
async voiceToggleAiFormat() {
|
|
if (this.state.voiceStatus !== 'review') {
|
|
return;
|
|
}
|
|
|
|
this.state.voiceAiFormat = !this.state.voiceAiFormat;
|
|
|
|
if (this.state.voiceAiFormat) {
|
|
if (this.state.voiceFormattedText) {
|
|
// Use cached formatted text
|
|
this.state.voiceText = this.state.voiceFormattedText;
|
|
} else {
|
|
// Fetch from GPT
|
|
await this._voiceFormatText();
|
|
}
|
|
} else {
|
|
// Switch back to raw transcription
|
|
this.state.voiceText = this.state.voiceRawText;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Post the current voice note text to chatter.
|
|
*/
|
|
async voicePostNote() {
|
|
await this._voicePostNote();
|
|
},
|
|
|
|
/**
|
|
* Cancel the voice note review and reset to idle.
|
|
*/
|
|
voiceCancelNote() {
|
|
this._voiceCleanup();
|
|
Object.assign(this.state, {
|
|
voiceStatus: 'idle',
|
|
voiceText: '',
|
|
voiceRawText: '',
|
|
voiceFormattedText: '',
|
|
voiceError: '',
|
|
voiceDuration: 0,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Cancel an active recording without transcribing.
|
|
*/
|
|
voiceCancelRecording() {
|
|
this._voiceRecorder.cancel();
|
|
if (this._voiceSpeechRecognizer) {
|
|
this._voiceSpeechRecognizer.cancel();
|
|
this._voiceSpeechRecognizer = null;
|
|
}
|
|
clearInterval(this._voiceTimer);
|
|
this._voiceTimer = null;
|
|
this.state.voiceStatus = 'idle';
|
|
this.state.voiceDuration = 0;
|
|
},
|
|
|
|
/**
|
|
* Format seconds as M:SS for the recording timer display.
|
|
*/
|
|
voiceFormatDuration(seconds) {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
},
|
|
|
|
/**
|
|
* Handle textarea input for the review panel.
|
|
*/
|
|
onVoiceTextInput(ev) {
|
|
this.state.voiceText = ev.target.value;
|
|
},
|
|
|
|
/**
|
|
* Override toggleComposer to close voice note review when the user
|
|
* opens the normal Send message / Log note composer.
|
|
*/
|
|
toggleComposer(mode = false, options = {}) {
|
|
if (this.state.voiceStatus === 'review') {
|
|
this.voiceCancelNote();
|
|
}
|
|
return super.toggleComposer(mode, options);
|
|
},
|
|
|
|
// ===================================================================
|
|
// Private methods
|
|
// ===================================================================
|
|
|
|
/**
|
|
* Send the raw transcription to GPT for professional formatting.
|
|
*/
|
|
async _voiceFormatText() {
|
|
const previousStatus = this.state.voiceStatus;
|
|
this.state.voiceStatus = 'formatting';
|
|
|
|
try {
|
|
const result = await rpc('/fusion_notes/format', {
|
|
text: this.state.voiceRawText,
|
|
});
|
|
|
|
if (result.error) {
|
|
this._voiceNotify(
|
|
`Formatting failed: ${result.error}`,
|
|
'warning'
|
|
);
|
|
this.state.voiceAiFormat = false;
|
|
this.state.voiceText = this.state.voiceRawText;
|
|
} else {
|
|
this.state.voiceFormattedText = result.text;
|
|
this.state.voiceText = result.text;
|
|
}
|
|
} catch {
|
|
this._voiceNotify(
|
|
'AI formatting failed. Showing raw text.',
|
|
'warning'
|
|
);
|
|
this.state.voiceAiFormat = false;
|
|
this.state.voiceText = this.state.voiceRawText;
|
|
}
|
|
|
|
// Restore appropriate status
|
|
this.state.voiceStatus =
|
|
previousStatus === 'transcribing' || previousStatus === 'formatting'
|
|
? 'review'
|
|
: previousStatus;
|
|
},
|
|
|
|
/**
|
|
* Post the voice note to the thread's chatter via RPC.
|
|
*/
|
|
async _voicePostNote() {
|
|
if (!this.state.voiceText || !this.state.voiceText.trim()) {
|
|
this._voiceNotify('Cannot post an empty note.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const thread = this.state.thread;
|
|
if (!thread || !thread.id) {
|
|
this._voiceNotify(
|
|
'Cannot post: no active record.',
|
|
'warning'
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await rpc('/fusion_notes/post_note', {
|
|
thread_model: thread.model,
|
|
thread_id: thread.id,
|
|
body: this.state.voiceText,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
// Success - reset state and refresh chatter messages
|
|
this._voiceNotify('Voice note posted.', 'success');
|
|
this.voiceCancelNote();
|
|
this.onPostCallback();
|
|
} catch (error) {
|
|
this._voiceNotify(
|
|
`Failed to post note: ${error.message || 'Unknown error'}`,
|
|
'danger'
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Show a notification to the user.
|
|
*/
|
|
_voiceNotify(message, type = 'info') {
|
|
if (this.notificationService) {
|
|
this.notificationService.add(message, { type });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clean up all voice recording resources.
|
|
*/
|
|
_voiceCleanup() {
|
|
if (this._voiceTimer) {
|
|
clearInterval(this._voiceTimer);
|
|
this._voiceTimer = null;
|
|
}
|
|
if (this._voiceRecorder) {
|
|
this._voiceRecorder.cancel();
|
|
}
|
|
if (this._voiceSpeechRecognizer) {
|
|
this._voiceSpeechRecognizer.cancel();
|
|
this._voiceSpeechRecognizer = null;
|
|
}
|
|
},
|
|
});
|