PageSourceSearch

https://app.medi-one.ai/modules/voiceControl.js

js medi-one.ai collected 2026-10-05 14:13:17 UTC 64,833 bytes, 1,513 lines download raw bytes

1import { Auth } from './auth.js';
2// phase8-voicecontrol-migrated: removed direct firestore import
3import { agentsApi } from './agentsApi.js';
4import { phoneNumbersApi } from './phoneNumbersApi.js';
5import { usersApi } from './usersApi.js';
6import UI from './ui.js?v=20260820-dd1-imperr1-bann1-impauth1';
7import { MultiCall } from './multiCall.js';
8import { Language } from './language.js';
9
10
11// ============================================================
12// phase8-voicecontrol-helpers: API replacements for FS reads
13// ============================================================
14async function _vc_authedFetch(url, options = {}) {
15    const token = await Auth.getIdToken();
16    const headers = {
17        'Authorization': `Bearer ${token}`,
18        'Content-Type': 'application/json',
19        ...(options.headers || {}),
20    };
21    const r = await fetch(url, { ...options, headers });
22    if (!r.ok) {
23        let msg = `HTTP ${r.status}`;
24        try { const j = await r.json(); if (j && j.error) msg += `: ${j.error}`; } catch (e) {}
25        const err = new Error(msg); err.status = r.status; throw err;
26    }
27    if (r.status === 204) return null;
28    return r.json();
29}
30async function _vc_getUserMe() {
31    try { return await usersApi.me(); }
32    catch (e) { console.error('phase8 _vc_getUserMe failed:', e); return null; }
33}
34async function _vc_listContacts() {
35    try {
36        const j = await _vc_authedFetch('/api/contacts');
37        return Array.isArray(j) ? j : (j.contacts || j.items || j.data || []);
38    } catch (e) { console.error('phase8 _vc_listContacts failed:', e); return []; }
39}
40async function _vc_findOutboundAgentId(fromNumber) {
41    if (!fromNumber) return null;
42    try {
43        const list = await phoneNumbersApi.list();
44        const arr = Array.isArray(list) ? list : (list.phoneNumbers || list.items || []);
45        const hit = arr.find(p => (p.phoneNumber === fromNumber || p.phone_number === fromNumber) && (p.outboundEnabled === true || p.outbound_enabled === true));
46        return hit ? (hit.assignedAgentId || hit.assigned_agent_id || null) : null;
47    } catch (e) { console.error('phase8 _vc_findOutboundAgentId failed:', e); return null; }
48}
49async function _vc_findAgentForUser(uid) {
50    try {
51        const list = await agentsApi.list();
52        const byUser = list.find(a => (a.userID === uid) || (a.userId === uid));
53        if (byUser) return byUser;
54        const byCreator = list.find(a => (a.createdBy === uid));
55        return byCreator || null;
56    } catch (e) { console.error('phase8 _vc_findAgentForUser failed:', e); return null; }
57}
58async function _vc_getContact(contactId) {
59    try { return await _vc_authedFetch(`/api/contacts/${encodeURIComponent(contactId)}`); }
60    catch (e) { console.error('phase8 _vc_getContact failed:', e); return null; }
61}
62async function _vc_createContactTask(contactId, taskBody) {
63    return _vc_authedFetch(`/api/contacts/${encodeURIComponent(contactId)}/tasks`, {
64        method: 'POST',
65        body: JSON.stringify(taskBody || {}),
66    });
67}
68async function _vc_createMulticallEntry(payload) {
69    return _vc_authedFetch('/api/multicall', {
70        method: 'POST',
71        body: JSON.stringify(payload || {}),
72    });
73}
74
75// Kurze Übersetzungsfunktion für einfacheren Zugriff
76function t(text) {
77    return Language.translate(text);
78}
79
80export class VoiceControl {
81    constructor() {
82        this.recognition = null;
83        this.isListening = false;
84        this.manualStop = false;
85        this.currentTranscript = '';
86        this.contacts = [];
87        this.selectedContact = null;
88        this.commands = {
89            call: {
90                patterns: [
91                    // Flexiblere Patterns - Kontext ist optional
92                    /rufe?\s+(?:bitte\s+)?(.+?)\s+an(?:\s+(?:um|und|wegen|für|bezüglich)\s+(.+))?/i,  // "rufe Michael Horn an" oder "rufe Michael Horn an um/und/wegen..."
93                    /(?:bitte\s+)?(.+?)\s+anrufen(?:\s+(?:um|und|wegen|für|bezüglich)\s+(.+))?/i,  // "Michael Horn anrufen"
94                    /kontaktiere\s+(?:bitte\s+)?(.+?)(?:\s+(?:und|für|wegen|bezüglich|um)\s+(.+))?/i,  // "kontaktiere Michael Horn"
95                    /anruf\s+(?:an\s+|bei\s+)?(.+?)(?:\s+(?:wegen|für|bezüglich|um)\s+(.+))?/i,  // "anruf bei Michael Horn"
96                    /frage?\s+(.+?)\s+(?:nach\s+|wegen\s+|bezüglich\s+)(.+)/i,  // "frage Michael Horn nach..."
97                    /call\s+(.+?)(?:\s+(?:about|regarding|for)\s+(.+))?/i,  // English: "call Michael Horn"
98                    // Neue flexible Patterns
99                    /telefoniere?\s+(?:mit\s+)?(.+?)(?:\s+(?:wegen|für|bezüglich|um)\s+(.+))?/i,  // "telefoniere mit Michael Horn"
100                    /(?:ich\s+)?(?:möchte|will)\s+(.+?)\s+(?:anrufen|kontaktieren)(?:\s+(?:um|und|wegen|für)\s+(.+))?/i,  // "ich möchte Michael Horn anrufen"
101                    /(?:ich\s+)?(?:möchte|will)\s+(?:gerne\s+)?mit\s+(.+?)\s+sprechen(?:\s+(?:über|wegen|bezüglich)\s+(.+))?/i,  // "ich möchte mit Michael Horn sprechen"
102                    /(?:ich\s+)?(?:muss|sollte|würde\s+gerne?)\s+(?:mal\s+)?mit\s+(.+?)\s+(?:sprechen|reden|telefonieren)(?:\s+(?:über|wegen|bezüglich)\s+(.+))?/i,  // "ich muss mit Michael Horn sprechen"
103                    /spreche?\s+(?:mal\s+)?(?:mit\s+)?(.+?)(?:\s+(?:über|wegen|bezüglich)\s+(.+))?/i,  // "sprich mit Michael Horn"
104                    /rede?\s+(?:mal\s+)?mit\s+(.+?)(?:\s+(?:über|wegen|bezüglich)\s+(.+))?/i,  // "rede mit Michael Horn"
105                    /(?:kannst\s+du\s+)?(?:bitte\s+)?(.+?)\s+(?:für\s+mich\s+)?(?:anrufen|kontaktieren)(?:\s+(?:und|wegen)\s+(.+))?/i  // "kannst du Michael Horn anrufen"
106                ],
107                action: 'initiateCall'
108            },
109            appointment: {
110                patterns: [
111                    /vereinbare\s+(?:einen\s+)?termin\s+(?:mit\s+|bei\s+)?(.+?)\s+(?:für\s+|wegen\s+|bezüglich\s+)(.+)/i,
112                    /termin\s+(?:bei|mit)\s+(.+?)\s+(?:für\s+|wegen\s+)(.+)/i,
113                    /schedule\s+(?:an?\s+)?appointment\s+(?:with\s+)?(.+?)(?:\s+for\s+(.+))?/i
114                ],
115                action: 'scheduleAppointment'
116            }
117        };
118        
119        this.init();
120    }
121    
122    init() {
123        // Setup UI Event Handlers first
124        this.setupUIHandlers();
125        
126        // Load contacts for autocomplete
127        this.loadContacts();
128        
129        // Initialize Speech Recognition will be done when panel opens
130        this.setupSpeechRecognition();
131    }
132    
133    setupSpeechRecognition() {
134        console.log('Setting up speech recognition');
135        
136        if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
137            console.error('Speech recognition not supported');
138            const statusText = document.querySelector('.voice-status-text');
139            if (statusText) {
140                statusText.textContent = t('Spracherkennung nicht unterstützt');
141            }
142            return;
143        }
144        
145        // Cleanup existing recognition instance
146        if (this.recognition) {
147            try {
148                if (this.isListening) {
149                    console.log('Stopping existing recognition');
150                    this.recognition.stop();
151                }
152            } catch (e) {
153                console.error('Error stopping recognition:', e);
154            }
155            this.recognition.onresult = null;
156            this.recognition.onend = null;
157            this.recognition.onerror = null;
158            this.recognition.onstart = null;
159            this.isListening = false;
160        }
161        
162        const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
163        this.recognition = new SpeechRecognition();
164        
165        this.recognition.continuous = true;
166        this.recognition.interimResults = true;
167        // Always use German for voice commands
168        this.recognition.lang = 'de-DE';
169        this.recognition.maxAlternatives = 1;
170        
171        this.setupRecognitionHandlers();
172    }
173    
174    setupRecognitionHandlers() {
175        this.recognition.onresult = (event) => {
176            let finalTranscript = '';
177            let interimTranscript = '';
178            
179            for (let i = event.resultIndex; i < event.results.length; i++) {
180                const transcript = event.results[i][0].transcript;
181                if (event.results[i].isFinal) {
182                    finalTranscript += transcript;
183                } else {
184                    interimTranscript += transcript;
185                }
186            }
187            
188            // Update input field
189            const input = document.getElementById('voiceCommandInput');
190            if (input) {
191                input.value = finalTranscript || interimTranscript;
192                this.currentTranscript = input.value;
193                this.showSuggestions(input.value);
194            }
195        };
196        
197        this.recognition.onstart = () => {
198            console.log('Speech recognition started');
199            this.isListening = true;
200            this.updateUI('listening');
201        };
202        
203        this.recognition.onend = () => {
204            console.log('Speech recognition ended');
205            this.isListening = false;
206            this.updateUI('stopped');
207            
208            // Auto-restart if panel is still open
209            const panel = document.getElementById('voiceInputPanel');
210            if (panel && panel.classList.contains('active') && !this.manualStop) {
211                console.log('Auto-restarting recognition');
212                setTimeout(() => this.startListening(), 500);
213            }
214        };
215        
216        this.recognition.onerror = (event) => {
217            console.error('Speech recognition error:', event.error, event);
218            this.isListening = false;
219            
220            if (event.error === 'aborted') {
221                console.log('Recognition aborted');
222                return;
223            }
224            
225            this.updateUI('error');
226            
227            if (event.error === 'no-speech') {
228                // Don't show error for no-speech, just restart
229                const panel = document.getElementById('voiceInputPanel');
230                if (panel && panel.classList.contains('active')) {
231                    setTimeout(() => this.startListening(), 1000);
232                }
233            } else if (event.error === 'not-allowed') {
234                UI.showError(t('Mikrofonzugriff wurde verweigert. Bitte erlauben Sie den Zugriff in den Browsereinstellungen.'));
235            } else if (event.error === 'network') {
236                UI.showError(t('Netzwerkfehler. Bitte überprüfen Sie Ihre Internetverbindung.'));
237            }
238        };
239    }
240    
241    setupUIHandlers() {
242        // Mic button click
243        const micButton = document.getElementById('voiceMicButton');
244        if (micButton) {
245            micButton.addEventListener('click', () => this.togglePanel());
246        }
247        
248        // Input field events
249        const input = document.getElementById('voiceCommandInput');
250        if (input) {
251            input.addEventListener('input', (e) => {
252                this.currentTranscript = e.target.value;
253                console.log('Input changed:', e.target.value);
254                this.showSuggestions(e.target.value);
255            });
256            
257            input.addEventListener('keydown', (e) => {
258                // For textarea: Ctrl+Enter or Cmd+Enter to execute
259                if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
260                    e.preventDefault();
261                    console.log('Ctrl/Cmd+Enter pressed, executing command');
262                    this.execute();
263                }
264            });
265        }
266    }
267    
268    togglePanel() {
269        const panel = document.getElementById('voiceInputPanel');
270        const micButton = document.getElementById('voiceMicButton');
271        
272        if (!panel) return;
273        
274        if (panel.classList.contains('active')) {
275            this.close();
276        } else {
277            panel.classList.add('active');
278            const input = document.getElementById('voiceCommandInput');
279            if (input) {
280                // Set translated placeholder
281                input.placeholder = t('Sprechen Sie oder tippen Sie Ihren Befehl...');
282                input.focus();
283                // Clear previous input
284                input.value = '';
285                this.currentTranscript = '';
286            }
287            
288            // Reload contacts when opening panel
289            this.loadContacts().then(() => {
290                // Show initial suggestions after contacts are loaded
291                this.showSuggestions('');
292            });
293            
294            // Start listening automatically if recognition is available
295            if (this.recognition) {
296                setTimeout(() => {
297                    // Re-setup recognition to ensure clean state
298                    this.setupSpeechRecognition();
299                    this.startListening();
300                }, 300);
301            }
302        }
303    }
304    
305    startListening() {
306        if (!this.recognition) {
307            console.error('Recognition not initialized');
308            return;
309        }
310        
311        if (this.isListening) {
312            console.log('Already listening');
313            return;
314        }
315        
316        this.manualStop = false;
317        
318        try {
319            console.log('Starting speech recognition');
320            this.recognition.start();
321        } catch (error) {
322            console.error('Error starting recognition:', error);
323            
324            if (error instanceof DOMException && error.name === 'InvalidStateError') {
325                console.log('Recognition already started, stopping first');
326                try {
327                    this.recognition.stop();
328                } catch (stopError) {
329                    console.error('Error stopping recognition:', stopError);
330                }
331                setTimeout(() => this.startListening(), 200);
332            }
333        }
334    }
335    
336    stopListening() {
337        console.log('Stopping speech recognition');
338        this.manualStop = true;
339        
340        if (this.recognition) {
341            try {
342                this.recognition.stop();
343            } catch (error) {
344                console.error('Error stopping recognition:', error);
345            }
346        }
347        
348        this.isListening = false;
349        this.updateUI('stopped');
350    }
351    
352    updateUI(status) {
353        const micButton = document.getElementById('voiceMicButton');
354        const statusIcon = document.querySelector('.voice-status-icon');
355        const statusText = document.querySelector('.voice-status-text');
356        
357        switch (status) {
358            case 'listening':
359                micButton?.classList.add('listening');
360                statusIcon?.classList.add('recording');
361                if (statusText) statusText.textContent = t('Zuhören...');
362                break;
363            case 'stopped':
364                micButton?.classList.remove('listening');
365                statusIcon?.classList.remove('recording');
366                if (statusText) statusText.textContent = t('Befehl eingeben');
367                break;
368            case 'error':
369                micButton?.classList.remove('listening');
370                if (statusText) statusText.textContent = t('Fehler');
371                break;
372        }
373    }
374    
375    async loadContacts() {
376        try {
377            const user = Auth.getCurrentUser();
378            if (!user) { console.log('No user found, cannot load contacts'); return; }
379            // phase8-voicecontrol-migrated: API-only
380            const all = await _vc_listContacts();
381            const withPhone = all.filter(c => c && c.phone_number && String(c.phone_number).trim() !== '');
382            this.contacts = withPhone.length > 0 ? withPhone : all;
383            console.log(`Loaded ${this.contacts.length} contacts via API`);
384            if (this.contacts.length > 0) {
385                console.log('Sample contacts:', this.contacts.slice(0, 5).map(c => ({
386                    name: c.name, 
387                    phone: c.phone_number,
388                    tenantID: c.tenantID
389                })));
390                
391                // Search for "Michael" in contacts
392                const michaelContacts = this.contacts.filter(c => 
393                    c.name && c.name.toLowerCase().includes('michael')
394                );
395                console.log(`Found ${michaelContacts.length} contacts with "Michael" in name:`);
396                if (michaelContacts.length > 0) {
397                    console.log('All Michael contacts:', michaelContacts.map(c => c.name));
398                }
399                
400                // Check exact match
401                const exactMatch = this.contacts.find(c => 
402                    c.name && c.name.toLowerCase() === 'michael horn'
403                );
404                if (exactMatch) {
405                    console.log('Found exact match for "Michael Horn":', {
406                        name: exactMatch.name,
407                        phone: exactMatch.phone_number,
408                        hasPhone: !!exactMatch.phone_number,
409                        id: exactMatch.id
410                    });
411                    
412                    // Check if in filtered list
413                    const inFilteredList = this.contacts.some(c => c.id === exactMatch.id);
414                    console.log('Michael Horn is in filtered contacts list:', inFilteredList);
415                } else {
416                    console.log('No exact match found for "Michael Horn"');
417                }
418            }
419        } catch (error) {
420            console.error('Error loading contacts:', error);
421            console.error('Error details:', error.message, error.code);
422        }
423    }
424    
425    showSuggestions(input) {
426        const suggestionsContainer = document.getElementById('voiceSuggestions');
427        if (!suggestionsContainer) return;
428        
429        suggestionsContainer.innerHTML = '';
430        
431        // Extract contact name from command if present
432        let searchInput = input;
433        if (input && input.length > 2) {
434            // Try to parse the command to extract just the contact name
435            const parsedCommand = this.parseCommand(input);
436            if (parsedCommand && parsedCommand.contactName) {
437                searchInput = parsedCommand.contactName;
438                console.log('Extracted contact name from command:', searchInput);
439            }
440        }
441        
442        if (!input || input.length < 2) {
443            // Show example commands
444            const examples = [
445                { icon: 'fa-phone', text: t('Rufe Max Mustermann an') },
446                { icon: 'fa-calendar', text: t('Vereinbare einen Termin mit Anna Schmidt') },
447                { icon: 'fa-redo', text: t('Kontaktiere Peter Müller für Follow-up') }
448            ];
449            
450            examples.forEach(example => {
451                const item = document.createElement('div');
452                item.className = 'voice-suggestion-item';
453                item.innerHTML = `<i class="fas ${example.icon}"></i>${example.text}`;
454                item.onclick = () => {
455                    const input = document.getElementById('voiceCommandInput');
456                    if (input) {
457                        input.value = example.text;
458                        this.currentTranscript = example.text;
459                    }
460                };
461                suggestionsContainer.appendChild(item);
462            });
463        } else {
464            // Show matching contacts based on extracted name or full input
465            const matchingContacts = this.findMatchingContacts(searchInput);
466            
467            if (matchingContacts.length > 0) {
468                matchingContacts.forEach(contact => {
469                    const item = document.createElement('div');
470                    item.className = 'voice-suggestion-item';
471                    item.innerHTML = `
472                        <i class="fas fa-user"></i>
473                        ${contact.name} 
474                        ${contact.company ? `<small>(${contact.company})</small>` : ''}
475                    `;
476                    item.onclick = () => {
477                        const currentInput = document.getElementById('voiceCommandInput').value;
478                        const updatedInput = this.replaceContactName(currentInput, contact.name);
479                        const input = document.getElementById('voiceCommandInput');
480                        if (input) {
481                            input.value = updatedInput;
482                            this.currentTranscript = updatedInput;
483                        }
484                        this.selectedContact = contact;
485                        console.log('Contact selected from suggestions:', contact.name, contact.phone_number);
486                    };
487                    suggestionsContainer.appendChild(item);
488                });
489            } else {
490                // No matching contacts
491                const item = document.createElement('div');
492                item.className = 'voice-suggestion-item';
493                item.style.color = '#999';
494                item.innerHTML = `<i class="fas fa-info-circle"></i>${t('Keine passenden Kontakte gefunden')}`;
495                suggestionsContainer.appendChild(item);
496            }
497        }
498    }
499    
500    findMatchingContacts(input) {
501        const searchTerms = input.toLowerCase().split(' ').filter(t => t.length > 0);
502        
503        console.log('Finding matching contacts for:', input, 'Terms:', searchTerms);
504        
505        const matches = this.contacts.filter(contact => {
506            const contactName = contact.name?.toLowerCase() || '';
507            const contactCompany = contact.company?.toLowerCase() || '';
508            const contactPhone = contact.phone_number || '';
509            
510            // Check if ALL search terms appear in the contact (not just some)
511            const allTermsMatch = searchTerms.every(term => 
512                contactName.includes(term) || 
513                contactCompany.includes(term) ||
514                contactPhone.includes(term)
515            );
516            
517            return allTermsMatch;
518        });
519        
520        // Sort by relevance - exact matches first
521        matches.sort((a, b) => {
522            const aName = a.name?.toLowerCase() || '';
523            const bName = b.name?.toLowerCase() || '';
524            const searchLower = input.toLowerCase();
525            
526            // Exact match gets highest priority
527            if (aName === searchLower) return -1;
528            if (bName === searchLower) return 1;
529            
530            // Then prioritize matches that start with the search term
531            if (aName.startsWith(searchLower)) return -1;
532            if (bName.startsWith(searchLower)) return 1;
533            
534            return 0;
535        });
536        
537        const results = matches.slice(0, 5);
538        console.log('Found matches:', results.map(c => c.name));
539        
540        return results;
541    }
542    
543    replaceContactName(input, contactName) {
544        // Intelligent replacement of partial names with full names
545        const words = input.split(' ');
546        let bestMatch = -1;
547        let bestScore = 0;
548        
549        // Find best matching position
550        for (let i = 0; i < words.length; i++) {
551            const score = this.calculateMatchScore(words.slice(i).join(' '), contactName);
552            if (score > bestScore) {
553                bestScore = score;
554                bestMatch = i;
555            }
556        }
557        
558        if (bestMatch >= 0) {
559            // Calculate how many words to replace
560            const contactWords = contactName.split(' ').length;
561            const before = words.slice(0, bestMatch);
562            const after = words.slice(bestMatch + contactWords);
563            return [...before, contactName, ...after].join(' ').trim();
564        }
565        
566        return input;
567    }
568    
569    calculateMatchScore(partial, full) {
570        const partialLower = partial.toLowerCase();
571        const fullLower = full.toLowerCase();
572        
573        if (fullLower.startsWith(partialLower)) {
574            return partialLower.length / fullLower.length;
575        }
576        
577        // Check if any word in partial matches any word in full
578        const partialWords = partialLower.split(' ');
579        const fullWords = fullLower.split(' ');
580        
581        let matchCount = 0;
582        partialWords.forEach(pWord => {
583            if (fullWords.some(fWord => fWord.startsWith(pWord))) {
584                matchCount++;
585            }
586        });
587        
588        return matchCount / fullWords.length;
589    }
590    
591    async execute() {
592        // Get input from text field (may be different from currentTranscript if manually edited)
593        const inputField = document.getElementById('voiceCommandInput');
594        const input = (inputField ? inputField.value : this.currentTranscript).trim();
595        
596        // Update currentTranscript with actual input
597        this.currentTranscript = input;
598        
599        console.log('Executing command with input:', input);
600        console.log('Current transcript was:', this.currentTranscript);
601        
602        if (!input) {
603            UI.showError(t('Bitte geben Sie einen Befehl ein.'));
604            return;
605        }
606        
607        // Stop listening
608        this.stopListening();
609        
610        // Parse command
611        const parsedCommand = this.parseCommand(input);
612        
613        if (parsedCommand) {
614            console.log('Command recognized, executing:', parsedCommand);
615            try {
616                await this.executeCommand(parsedCommand);
617                this.close();
618            } catch (error) {
619                console.error('Error executing command:', error);
620                UI.showError(t('Fehler beim Ausführen des Befehls.'));
621            }
622        } else {
623            console.log('Command not recognized for input:', input);
624            UI.showError(t('Befehl nicht erkannt. Bitte versuchen Sie es erneut.'));
625        }
626    }
627    
628    parseCommand(input) {
629        console.log('Parsing command:', input);
630        
631        // Zuerst versuchen wir die definierten Patterns
632        for (const [commandType, config] of Object.entries(this.commands)) {
633            for (const pattern of config.patterns) {
634                const match = input.match(pattern);
635                if (match) {
636                    const result = {
637                        type: commandType,
638                        action: config.action,
639                        contactName: match[1]?.trim(),
640                        context: match[2]?.trim() || ''
641                    };
642                    console.log('Command parsed with pattern:', result);
643                    return result;
644                }
645            }
646        }
647        
648        // ULTRA-FLEXIBLE ERKENNUNG
649        const inputLower = input.toLowerCase().trim();
650        const words = inputLower.split(/\s+/);
651        
652        // 1. Fuzzy-Matching für Kontakte (Teilübereinstimmung, Tippfehler-tolerant)
653        let bestMatch = null;
654        let bestScore = 0;
655        
656        for (const contact of this.contacts) {
657            if (!contact.name) continue;
658            const contactNameLower = contact.name.toLowerCase();
659            const contactWords = contactNameLower.split(/\s+/);
660            
661            // Score berechnen basierend auf verschiedenen Faktoren
662            let score = 0;
663            
664            // Exakte Übereinstimmung
665            if (inputLower.includes(contactNameLower)) {
666                score = 100;
667            } 
668            // Alle Wörter des Kontaktnamens im Input
669            else if (contactWords.every(word => inputLower.includes(word))) {
670                score = 90;
671            }
672            // Teilweise Übereinstimmung der Wörter
673            else {
674                for (const contactWord of contactWords) {
675                    for (const inputWord of words) {
676                        // Exakte Wortübereinstimmung
677                        if (inputWord === contactWord) {
678                            score += 30;
679                        }
680                        // Wort beginnt gleich (mindestens 3 Zeichen)
681                        else if (contactWord.length >= 3 && inputWord.length >= 3) {
682                            if (contactWord.startsWith(inputWord) || inputWord.startsWith(contactWord)) {
683                                score += 20;
684                            }
685                            // Levenshtein-ähnliche Prüfung für ähnliche Wörter
686                            else if (this.areSimilar(inputWord, contactWord)) {
687                                score += 15;
688                            }
689                        }
690                    }
691                }
692            }
693            
694            // Bonus für Vornamen-Match am Anfang
695            const firstName = contactWords[0];
696            if (firstName && words[0] === firstName) {
697                score += 10;
698            }
699            
700            if (score > bestScore || (score === bestScore && score > 0 && contact.name.length > (bestMatch?.name?.length || 0))) {
701                bestScore = score;
702                bestMatch = contact;
703            }
704        }
705        
706        // Wenn wir einen guten Match haben (Score > 30), verwenden wir ihn
707        if (bestMatch && bestScore > 30) {
708            console.log(`Found contact with fuzzy matching: ${bestMatch.name} (score: ${bestScore})`);
709            
710            // Extrahiere Kontext intelligent
711            let context = input;
712            const contactNameRegex = new RegExp(
713                bestMatch.name.split(/\s+/).join('\\s*'), 
714                'gi'
715            );
716            context = context.replace(contactNameRegex, '').trim();
717            
718            // Entferne alle Anruf-bezogenen Wörter für saubereren Kontext
719            const callWords = [
720                'rufe?', 'anrufen', 'kontaktiere', 'telefoniere', 'spreche?', 
721                'call', 'bitte', 'mal', 'doch', 'ich möchte', 'ich will',
722                'kannst du', 'könntest du', 'würdest du', 'an', 'mit'
723            ];
724            
725            for (const word of callWords) {
726                context = context.replace(new RegExp(`\\b${word}\\b`, 'gi'), '');
727            }
728            
729            // Entferne Verbindungswörter am Anfang/Ende
730            context = context.replace(/^(und|um|wegen|für|bezüglich|über|dass|ob|zur|zum|mit)\s+/i, '');
731            context = context.replace(/\s+(und|um|wegen|für|bezüglich|über)$/i, '');
732            context = context.trim();
733            
734            const result = {
735                type: 'call',
736                action: 'initiateCall',
737                contactName: bestMatch.name,
738                context: context
739            };
740            console.log('Command parsed with fuzzy detection:', result);
741            return result;
742        }
743        
744        // 2. Extrem flexibel: Wenn nur 1-3 Wörter eingegeben wurden, 
745        // suche nac
745h dem besten Kontakt-Match
746        if (words.length <= 3 && words.length >= 1) {
747            // Versuche jeden möglichen Kontakt zu finden
748            const searchTerm = inputLower;
749            const matches = this.contacts.filter(contact => {
750                if (!contact.name) return false;
751                const cName = contact.name.toLowerCase();
752                
753                // Prüfe ob alle eingegebenen Wörter im Namen vorkommen
754                return words.every(word => {
755                    // Wort ist im Namen enthalten ODER
756                    // Wort ist der Anfang eines Namensbestandteils
757                    return cName.includes(word) || 
758                           cName.split(/\s+/).some(namePart => namePart.startsWith(word));
759                });
760            });
761            
762            if (matches.length === 1) {
763                // Eindeutiger Match
764                const result = {
765                    type: 'call',
766                    action: 'initiateCall',
767                    contactName: matches[0].name,
768                    context: ''
769                };
770                console.log('Command parsed with single word match:', result);
771                return result;
772            } else if (matches.length > 1) {
773                // Mehrere Matches - nimm den mit der besten Übereinstimmung
774                const exactMatch = matches.find(m => 
775                    m.name.toLowerCase() === searchTerm
776                );
777                if (exactMatch) {
778                    const result = {
779                        type: 'call',
780                        action: 'initiateCall',
781                        contactName: exactMatch.name,
782                        context: ''
783                    };
784                    console.log('Command parsed with exact match from multiple:', result);
785                    return result;
786                }
787            }
788        }
789        
790        // 3. Letzter Versuch: Interpretiere alles als möglichen Namen
791        // wenn es wie ein Name aussieht (beginnt mit Großbuchstabe)
792        if (input.match(/^[A-ZÄÖÜ]/)) {
793            // Versuche progressiv kürzere Teile des Inputs als Namen
794            for (let nameLength = Math.min(3, words.length); nameLength > 0; nameLength--) {
795                const possibleName = words.slice(0, nameLength).join(' ');
796                
797                // Fuzzy-Suche nach diesem Namen
798                const nameMatches = this.contacts.filter(c => {
799                    if (!c.name) return false;
800                    const cName = c.name.toLowerCase();
801                    const searchName = possibleName.toLowerCase();
802                    
803                    // Flexibler Match
804                    return cName.includes(searchName) || 
805                           searchName.split(' ').every(part => cName.includes(part)) ||
806                           this.areSimilar(cName, searchName);
807                });
808                
809                if (nameMatches.length > 0) {
810                    const bestNameMatch = nameMatches[0];
811                    const context = words.slice(nameLength).join(' ');
812                    
813                    const result = {
814                        type: 'call',
815                        action: 'initiateCall',
816                        contactName: bestNameMatch.name,
817                        context: context
818                    };
819                    console.log('Command parsed with progressive name search:', result);
820                    return result;
821                }
822            }
823        }
824        
825        console.log('No command pattern matched - input too ambiguous');
826        return null;
827    }
828    
829    // Hilfsfunktion für Ähnlichkeitsprüfung (vereinfachte Levenshtein-Distanz)
830    areSimilar(str1, str2) {
831        // Strings zu kurz für sinnvollen Vergleich
832        if (str1.length < 3 || str2.length < 3) return false;
833        
834        // Mehr als 2 Zeichen Längenunterschied = unähnlich
835        if (Math.abs(str1.length - str2.length) > 2) return false;
836        
837        // Zähle unterschiedliche Zeichen
838        let differences = 0;
839        const maxLen = Math.max(str1.length, str2.length);
840        
841        for (let i = 0; i < maxLen; i++) {
842            if (str1[i] !== str2[i]) {
843                differences++;
844            }
845        }
846        
847        // Erlaubt 1-2 Tippfehler je nach Wortlänge
848        return differences <= Math.ceil(maxLen * 0.3);
849    }
850    
851    async executeCommand(command) {
852        switch (command.action) {
853            case 'initiateCall':
854                await this.initiateCall(command.contactName, command.context);
855                break;
856            case 'scheduleAppointment':
857                await this.scheduleAppointment(command.contactName, command.context);
858                break;
859        }
860    }
861    
862    async initiateCall(contactName, context) {
863        console.log('Searching for contact:', contactName);
864        console.log('Available contacts:', this.contacts.length);
865
866        // Use previously selected contact if available
867        let contact = null;
868        if (this.selectedContact) {
869            contact = this.selectedContact;
870            console.log('Using previously selected contact:', contact.name, contact.phone_number);
871            this.selectedContact = null;
872        } else {
873            // Reload contacts if none are loaded
874            if (this.contacts.length === 0) {
875                console.log('No contacts loaded, reloading...');
876                await this.loadContacts();
877            }
878
879            // Normalize search term
880            const searchTerm = contactName.trim().toLowerCase();
881            console.log('Normalized search term:', searchTerm);
882
883            // Find contact - try exact match first
884            contact = this.contacts.find(c => {
885                const name = c.name?.trim().toLowerCase() || '';
886                return name === searchTerm;
887            });
888
889            if (contact) {
890                console.log('Found exact match:', contact.name);
891            }
892
893            // If no exact match, try partial match
894            if (!contact) {
895                console.log('No exact match, trying partial match...');
896                const searchWords = searchTerm.split(' ').filter(w => w.length > 0);
897
898                // Find contacts where all search words appear in the name
899                const matches = this.contacts.filter(c => {
900                    const contactNameLower = c.name?.toLowerCase() || '';
901                    return searchWords.every(word => contactNameLower.includes(word));
902                });
903
904                console.log(`Found ${matches.length} partial matches`);
905
906                if (matches.length === 1) {
907                    contact = matches[0];
908                    console.log('Using single partial match:', contact.name);
909                } else if (matches.length > 1) {
910                    // Try to find best match
911                    const exactWordMatch = matches.find(c => {
912                        const contactWords = c.name.toLowerCase().split(' ');
913                        return searchWords.every(sw => contactWords.some(cw => cw === sw));
914                    });
915
916                    if (exactWordMatch) {
917                        contact = exactWordMatch;
918                        console.log('Found exact word match:', contact.name);
919                    } else {
920                        console.log('Multiple matches found:', matches.slice(0, 5).map(c => c.name));
921                        UI.showError(t(`Mehrere Kontakte gefunden für "${contactName}". Bitte spezifizieren Sie genauer.`));
922                        return;
923                    }
924                }
925            }
926
927            if (!contact) {
928                UI.showError(t(`Kontakt "${contactName}" nicht gefunden.`));
929                console.log('Contact not found. Search term:', contactName);
930                console.log('First 20 contact names:', this.contacts.slice(0, 20).map(c => c.name));
931                return;
932            }
933        }
934        
935        if (!contact.phone_number) {
936            UI.showError(t(`Keine Telefonnummer für ${contactName} hinterlegt.`));
937            return;
938        }
939        
940        // Generate appropriate template based on context - use full transcript if context is incomplete
941        const fullContext = context || this.currentTranscript.replace(new RegExp(`rufe?\\s+${contactName}\\s+an\\s+(und\\s+)?`, 'i'), '').trim();
942        const template = await this.selectTemplate(fullContext);
943        
944        // Create task description from context or original command
945        const taskDescription = context ? 
946            `${context.charAt(0).toUpperCase() + context.slice(1)}` : 
947            t('Anruf durchführen');
948        
949        // Create MultiCall entry data
950        const multicallData = {
951            task: `${t('Sprachbefehl')}: ${taskDescription}`,
952            greeting: template.greeting.replace('{name}', contact.name),
953            message: template.message,
954            message2: template.message2 || '',
955            details: `${t('Kontakt')}: ${contact.name}\n${t('Befehl')}: "${this.currentTranscript}"`,
956            phone_number: contact.phone_number,
957            contact_id: contact.id,
958            created_via: 'voice_command'
959        };
960        
961        // Show confirmation
962        const confirmed = await this.showConfirmation(contact, multicallData);
963        
964        if (confirmed) {
965            try {
966                // Create task for the contact with call data
967                await this.createContactTask(contact.id, taskDescription, multicallData);
968                
969                // Create MultiCall entry and initiate call
970                await this.createAndStartCall(multicallData);
971                UI.showSuccess(t(`Anruf an ${contact.name} wird gestartet...`));
972            } catch (error) {
973                console.error('Error in voice call initiation:', error);
974                UI.showError(t('Fehler beim Erstellen des Anrufs'));
975            }
976        }
977    }
978    
979    async createContactTask(contactId, taskDescription, callData = {}) {
980        try {
981            const user = Auth.getCurrentUser();
982            if (!user) {
983                throw new Error('User not authenticated');
984            }
985            
986            // phase8-voicecontrol-migrated: tenant + outbound-agent via API
987            let tenantId = null;
988            try { tenantId = await Auth.getCurrentTenantId(); } catch (e) {}
989            const _me = await _vc_getUserMe();
990            if (!tenantId && _me) tenantId = _me.tenantID || _me.tenant_id || _me.tenantId;
991            let agentId = null;
992            const fromNumber = _me && _me.from_number;
993            if (fromNumber) agentId = await _vc_findOutboundAgentId(fromNumber);
994            if (!agentId) {
995                const a = await _vc_findAgentForUser(user.uid);
996                if (a) agentId = a.id;
997            }
998            
999            // phase8-voicecontrol-migrated: API
1000            const created = await _vc_createContactTask(contactId, {
1001                task: taskDescription,
1002                status: 'offen',
1003                attempt: 0,
1004                greeting: callData.greeting || '',
1005                message: callData.message || '',
1006                message2: callData.message2 || '',
1007                agent_id: agentId || undefined,
1008                source: 'voice_command',
1009                original_command: this.currentTranscript,
1010            });
1011            console.log('Task created for contact:', created.id);
1012            return created.id;
1013        } catch (error) {
1014            console.error('Error creating contact task:', error);
1015            throw error;
1016        }
1017    }
1018    
1019    async selectTemplate(context) {
1020        const contextLower = context?.toLowerCase() || '';
1021        
1022        // Get agent data for name
1023        let agentName = 'Ihr VoiceOne Assistent';
1024        let companyName = 'VoiceOne';
1025        
1026        try {
1027            const user = Auth.getCurrentUser();
1028            if (user) {
1029                let foundAgent = false;
1030
1031                // phase8-voicecontrol-migrated: selectTemplate
1032                const _me = await _vc_getUserMe();
1033                if (_me) {
1034                    companyName = _me.company || companyName;
1035                    const fromNumber = _me.from_number;
1036                    if (fromNumber) {
1037                        const aid = await _vc_findOutboundAgentId(fromNumber);
1038                        if (aid) {
1039                            const a = await agentsApi.get(aid).catch(() => null);
1040                            if (a) {
1041                                agentName = a.name || a.agentName || agentName;
1042                                companyName = a.company || companyName;
1043                                foundAgent = true;
1044                            }
1045                        }
1046                    }
1047                }
1048                if (!foundAgent) {
1049                    const a = await _vc_findAgentForUser(user.uid);
1050                    if (a) agentName = a.name || a.agentName || agentName;
1051                }
1052            }
1053        } catch (error) {
1054            console.log('Could not get agent data for template:', error);
1055        }
1056        
1057        // ALWAYS try to generate intelligent message using GPT first
1058        // Only skip for very simple cases
1059        const isSimpleCommand = (contextLower === 'termin' || contextLower === 'angebot' || contextLower === 'frage');
1060        
1061        if (!isSimpleCommand) {
1062            try {
1063                console.log('Attempting GPT generation for context:', context);
1064                const gptTemplate = await this.generateSmartMessage(context, agentName, companyName);
1065                if (gptTemplate) {
1066                    console.log('Using GPT-generated template:', gptTemplate);
1067                    return gptTemplate;
1068                }
1069            } catch (error) {
1070                console.error('Error generating smart message, falling back to templates:', error);
1071            }
1072        }
1073        
1074        // Fallback to context-specific templates only if context is simple
1075        if (contextLower.includes('termin') && !contextLower.includes('preis') && !contextLower.includes('€')) {
1076            return {
1077                greeting: t(`Guten Tag {name}, hier ist der ${agentName} von ${companyName}.`),
1078                message: t(`Ich rufe Sie an, um ${context}. Hätten Sie in den nächsten Tagen Zeit für ein Gespräch?`),
1079                message2: t('Ich kann Ihnen gerne einige Terminvorschläge machen.')
1080            };
1081        } else if (contextLower.includes('angebot')) {
1082            return {
1083                greeting: t(`Guten Tag {name}, hier ist der ${agentName} von ${companyName}.`),
1084                message: t(`Der Grund meines Anrufs: Ich möchte mit Ihnen ${context}. Haben Sie bereits Gelegenheit gehabt, darüber nachzudenken?`),
1085                message2: ''
1086            };
1087        } else if (contextLower.includes('frage')) {
1088            return {
1089                greeting: t(`Guten Tag {name}, hier ist der ${agentName} von ${companyName}.`),
1090                message: t(`Ich möchte ${context}. Passt es Ihnen gerade?`),
1091                message2: ''
1092            };
1093        } else if (context) {
1094            // Generic template with context
1095            return {
1096                greeting: t(`Guten Tag {name}, hier ist der ${agentName} von ${companyName}.`),
1097                message: t(`Der Grund meines Anrufs: Ich möchte mit Ihnen ${context}.`),
1098                message2: ''
1099            };
1100        }
1101        
1102        // Default template without specific context
1103        return this.getDefaultTemplate(agentName, companyName);
1104    }
1105    
1106    getDefaultTemplate(agentName = 'Ihr VoiceOne Assistent', companyName = 'VoiceOne') {
1107        return {
1108            greeting: t(`Guten Tag {name}, mein Name ist der ${agentName} von ${companyName}. Ich hoffe, ich erwische Sie gerade nicht ungünstig?`),
1109            message: t('Der Grund meines Anrufs: Ich möchte mit Ihnen über ein wichtiges Thema sprechen.'),
1110            message2: ''
1111        };
1112    }
1113    
1114    async generateSmartMessage(voiceCommand, agentName, companyName) {
1115        try {
1116            const user = Auth.getCurrentUser();
1117            if (!user) {
1118                throw new Error('User not authenticated');
1119            }
1120            
1121            const response = await fetch('/api/voice/generate-message', {
1122                method: 'POST',
1123                headers: {
1124                    'Content-Type': 'application/json',
1125                    'Authorization': 'Bearer ' + await user.getIdToken()
1126                },
1127                body: JSON.stringify({
1128                    voiceCommand: voiceCommand,
1129                    agentName: agentName,
1130                    companyName: companyName,
1131                    originalTranscript: this.currentTranscript
1132                })
1133            });
1134            
1135            if (!response.ok) {
1136                throw new Error('Failed to generate message');
1137            }
1138            
1139            const result = await response.json();
1140            return {
1141                greeting: result.greeting || t(`Guten Tag {name}, hier ist der ${agentName} von ${companyName}.`),
1142                message: result.message || voiceCommand,
1143                message2: result.message2 || ''
1144            };
1145        } catch (error) {
1146            console.error('Error calling GPT API:', error);
1147            return null;
1148        }
1149    }
1150    
1151    async loadAgentsForDropdown(contactName) {
1152        try {
1153            const user = Auth.getCurrentUser();
1154            if (!user) return;
1155
1156            const agentSelect = document.getElementById('voiceCallAgent');
1157            if (!agentSelect) return;
1158
1159            // phase8-voicecontrol-migrated: agents via API
1160            const _me = await _vc_getUserMe();
1161            const tenantId = _me ? (_me.tenantID || _me.tenant_id) : null;
1162            const fromNumber = _me && _me.from_number;
1163            if (!tenantId) return;
1164            const _all = await agentsApi.list();
1165            const agents = _all
1166                .filter(a => a && a.name)
1167                .map(a => ({ id: a.id, name: a.name, company: a.company, selfHosted: !!a.selfHosted }));
1168            // Sort: selfHosted agents first, then alphabetically
1169            agents.sort((a, b) => {
1170                if (a.selfHosted && !b.selfHosted) return -1;
1171                if (!a.selfHosted && b.selfHosted) return 1;
1172                return (a.name || '').localeCompare(b.name || '');
1173            });
1174
1175            if (agents.length === 0) {
1176                agentSelect.innerHTML = `<option value="">${t('Kein Agent verfügbar')}</option>`;
1177                return;
1178            }
1179
1180            // Determine which agent should be pre-selected (phone number assignment)
1181            let preselectedAgentId = '';
1182            if (fromNumber) {
1183                preselectedAgentId = (await _vc_findOutboundAgentId(fromNumber)) || '';
1184            }
1185
1186            // Populate dropdown
1187            agentSelect.innerHTML = agents.map(agent => {
1188                const selected = agent.id === preselectedAgentId ? 'selected' : '';
1189                const label = agent.company ? `${agent.name} (${agent.company})` : agent.name;
1190                return `<option value="${agent.id}" ${selected}>${label}</option>`;
1191            }).join('');
1192
1193            // Update greeting when agent changes
1194            agentSelect.addEventListener('change', () => {
1195                const selectedAgent = agents.find(a => a.id === agentSelect.value);
1196                if (selectedAgent) {
1197                    const greetingField = document.getElementById('voiceCallGreeting');
1198                    if (greetingField) {
1199                        const name = contactName || '{name}';
1200                        greetingField.value = `Guten Tag ${name}, hier ist der ${selectedAgent.name} von ${selectedAgent.company || ''}.`;
1201                    }
1202                }
1203            });
1204
1205        } catch (error) {
1206            console.error('Error loading agents for dropdown:', error);
1207            const agentSelect = document.getElementById('voiceCallAgent');
1208            if (agentSelect) {
1209                agentSelect.innerHTML = `<option value="">${t('Fehler beim Laden')}</option>`;
1210            }
1211        }
1212    }
1213
1214    async showConfirmation(contact, callData) {
1215        return new Promise((resolve) => {
1216            const modal = document.createElement('div');
1217            modal.className = 'modal fade';
1218            modal.style.display = 'block';
1219            modal.style.backgroundColor = 'rgba(0,0,0,0.5)';
1220
1221            // Create unique ID for modal
1222            const modalId = 'voiceConfirmModal' + Date.now();
1223            modal.id = modalId;
1224
1225            modal.innerHTML = `
1226                <div class="modal-dialog modal-lg">
1227                    <div class="modal-content">
1228                        <div class="modal-header">
1229                            <h5 class="modal-title">${t('Anruf bearbeiten und bestätigen')}</h5>
1230                            <button type="button" class="btn-close" data-action="cancel"></button>
1231                        </div>
1232                        <div class="modal-body">
1233                            <form id="voiceCallForm">
1234                                <div class="row mb-3">
1235                                    <div class="col-md-6">
1236                                        <label class="form-label"><strong>${t('Kontakt')}:</strong></label>
1237                                        <input type="text" class="form-control-plaintext" value="${contact.name}" readonly>
1238                                    </div>
1239                                    <div class="col-md-6">
1240                                        <label class="form-label"><strong>${t('Telefon')}:</strong></label>
1241                                        <input type="text" class="form-control-plaintext" value="${contact.phone_number}" readonly>
1242                                    </div>
1243                                </div>
1244
1245                                <div class="mb-3">
1246                                    <label for="voiceCallAgent" class="form-label"><strong>${t('Agent')}:</strong></label>
1247                                    <select id="voiceCallAgent" class="form-select">
1248                                        <option value="">${t('Wird geladen...')}</option>
1249                                    </select>
1250                                </div>
1251
1252                                <div class="mb-3">
1253                                    <label for="voiceCallTask" class="form-label"><strong>${t('Aufgabe')}:</strong></label>
1254                                    <textarea id="voiceCallTask" class="form-control" rows="2">${callData.task}</textarea>
1255                                </div>
1256
1257                                <div class="mb-3">
1258                                    <label for="voiceCallGreeting" class="form-label"><strong>${t('Begrüßung')}:</strong></label>
1259                                    <textarea id="voiceCallGreeting" class="form-control" rows="3">${callData.greeting}</textarea>
1260                                    <small class="form-text text-muted">${t('Verwenden Sie {name} als Platzhalter für den Kontaktnamen')}</small>
1261                                </div>
1262
1263                                <div class="mb-3">
1264                                    <label for="voiceCallMessage" class="form-label"><strong>${t('Nachricht')}:</strong></label>
1265                                    <textarea id="voiceCallMessage" class="form-control" rows="3">${callData.message}</textarea>
1266                                </div>
1267
1268                                <div class="mb-3">
1269                                    <label for="voiceCallMessage2" class="form-label"><strong>${t('Weitere Details')}:</strong></label>
1270                                    <textarea id="voiceCallMessage2" class="form-control" rows="3">${callData.message2 || ''}</textarea>
1271                                </div>
1272
1273                                <div class="mb-3">
1274                                    <label for="voiceCallDetails" class="form-label"><strong>${t('Interne Notizen')}:</strong></label>
1275                                    <textarea id="voiceCallDetails" class="form-control" rows="2">${callData.details || ''}</textarea>
1276                                </div>
1277                            </form>
1278                        </div>
1279                        <div class="modal-footer">
1280                            <button class="btn btn-secondary" data-action="cancel">
1281                                ${t('Abbrechen')}
1282                            </button>
1283                            <button class="btn btn-primary" data-action="confirm">
1284                                <i class="fas fa-phone me-1"></i>
1285                                ${t('Anruf starten')}
1286                            </button>
1287                        </div>
1288                    </div>
1289                </div>
1290            `;
1291
1292            document.body.appendChild(modal);
1293
1294            // Load agents into dropdown
1295            this.loadAgentsForDropdown(contact.name).catch(err => {
1296                console.error('Error loading agents:', err);
1297            });
1298
1299            // Add Bootstrap modal classes after adding to DOM
1300            setTimeout(() => {
1301                modal.classList.add('show');
1302            }, 10);
1303
1304            // Handle button clicks
1305            const handleClick = (e) => {
1306                const action = e.target.dataset.action;
1307                if (action === 'confirm') {
1308                    // Update callData with edited values
1309                    callData.task = document.getElementById('voiceCallTask').value;
1310                    callData.greeting = document.getElementById('voiceCallGreeting').value;
1311                    callData.message = document.getElementById('voiceCallMessage').value;
1312                    callData.message2 = document.getElementById('voiceCallMessage2').value;
1313                    callData.details = document.getElementById('voiceCallDetails').value;
1314                    // Store selected agent ID
1315                    const agentSelect = document.getElementById('voiceCallAgent');
1316                    if (agentSelect && agentSelect.value) {
1317                        callData.agent_id = agentSelect.value;
1318                    }
1319                    
1320                    modal.classList.remove('show');
1321                    setTimeout(() => {
1322                        document.body.removeChild(modal);
1323                    }, 300);
1324                    resolve(true);
1325                } else if (action === 'cancel') {
1326                    modal.classList.remove('show');
1327                    setTimeout(() => {
1328                        document.body.removeChild(modal);
1329                    }, 300);
1330                    resolve(false);
1331                }
1332            };
1333            
1334            modal.addEventListener('click', handleClick);
1335        });
1336    }
1337    
1338    async createAndStartCall(callData) {
1339        try {
1340            const user = Auth.getCurrentUser();
1341            if (!user) {
1342                throw new Error('User not authenticated');
1343            }
1344            
1345            // phase8-voicecontrol-migrated: tenant + multicall create via API
1346            let tenantId = null;
1347            try { tenantId = await Auth.getCurrentTenantId(); } catch (e) {}
1348            const _me2 = await _vc_getUserMe();
1349            if (!tenantId && _me2) tenantId = _me2.tenantID || _me2.tenant_id;
1350            if (!tenantId) throw new Error('No tenant ID found');
1351            const newEntry = {
1352                ...callData,
1353                tenantID: String(tenantId),
1354                uid: user.uid,
1355                status: 'pending',
1356                voiceInitiated: true,
1357            };
1358            const created = await _vc_createMulticallEntry(newEntry);
1359            const docRef = { id: created.id || created._id || (created.data && created.data.id) };
1360            
1361            console.log('Voice-initiated call created:', docRef.id);
1362            
1363            // Use agent selected in dropdown, or find by phone number
1364            let agentId = callData.agent_id || null;
1365            if (agentId) {
1366                console.log('Using agent selected in dropdown:', agentId);
1367            } else {
1368                console.log('Looking for agent based on outbound phone number for user:', user.uid);
1369            }
1370            
1371            // phase8-voicecontrol-migrated: agent lookup via API
1372            if (!agentId) {
1373                try {
1374                    const fromNumber2 = _me2 && _me2.from_number;
1375                    if (fromNumber2) agentId = await _vc_findOutboundAgentId(fromNumber2);
1376                    if (!agentId) {
1377                        const _all = await agentsApi.list();
1378                        const def = _all.find(a => a && (a.isDefault === true) && (String(a.tenantID || a.tenantId || a.tenant_id) === String(tenantId)));
1379                        if (def) agentId = def.id;
1380                        else if (_all.length) agentId = _all[0].id;
1381                    }
1382                } catch (error) { console.error('Error finding agent:', error); }
1383            }
1384            
1385            if (!agentId) {
1386                UI.showError(t('Kein Agent für Anrufe konfiguriert'));
1387                throw new Error('No agent configured');
1388            }
1389            
1390            console.log('Found agent for call:', agentId);
1391            
1392            // Trigger the call through the backend
1393            try {
1394                const requestData = {
1395                    multicallId: docRef.id,
1396                    phoneNumber: callData.phone_number,
1397                    workflowId: 'default_outbound', // You can make this configurable
1398                    agentPrompt: this.generateAgentPrompt(callData),
1399                    metadata: {
1400                        initiated_via: 'voice_command',
1401                        contact_id: callData.contact_id,
1402                        original_command: this.currentTranscript,
1403                        agent_id: agentId,
1404                        tenant_id: tenantId
1405                    }
1406                };
1407                
1408                console.log('Sending request to /api/multicall/start:', requestData);
1409                
1410                const response = await fetch('/api/multicall/start', {
1411                    method: 'POST',
1412                    headers: {
1413                        'Content-Type': 'application/json',
1414                        'Authorization': 'Bearer ' + await user.getIdToken()
1415                    },
1416                    body: JSON.stringify(requestData)
1417                });
1418                
1419                if (!response.ok) {
1420                    const errorText = await response.text();
1421                    console.error('Server error response:', errorText);
1422                    throw new Error('Failed to start call: ' + errorText);
1423                }
1424                
1425                const result = await response.json();
1426                console.log('Call started:', result);
1427                
1428            } catch (apiError) {
1429                console.error('Error triggering call workflow:', apiError);
1430                // Don't throw - call entry is created, just couldn't start it immediately
1431                UI.showWarning(t('Anruf wurde erstellt, konnte aber nicht sofort gestartet werden.'));
1432            }
1433            
1434            return docRef.id;
1435        } catch (error) {
1436            console.error('Error creating voice-initiated call:', error);
1437            throw error;
1438        }
1439    }
1440    
1441    generateAgentPrompt(callData) {
1442        return `${callData.greeting}\n\n${callData.message}${callData.message2 ? '\n\n' + callData.message2 : ''}`;
1443    }
1444    
1445    async scheduleAppointment(contactName, context) {
1446        // This would integrate with your calendar system
1447        UI.showInfo(t(`Terminvereinbarung mit ${contactName} - Diese Funktion wird bald verfügbar sein.`));
1448    }
1449    
1450    close() {
1451        console.log('Closing voice control');
1452        this.manualStop = true;
1453        this.stopListening();
1454        
1455        const panel = document.getElementById('voiceInputPanel');
1456        if (panel) {
1457            panel.classList.remove('active');
1458        }
1459        
1460        const input = document.getElementById('voiceCommandInput');
1461        if (input) {
1462            input.value = '';
1463        }
1464        
1465        this.currentTranscript = '';
1466        
1467        // Clear suggestions
1468        const suggestionsContainer = document.getElementById('voiceSuggestions');
1469        if (suggestionsContainer) {
1470            suggestionsContainer.innerHTML = '';
1471        }
1472    }
1473    
1474    cancel() {
1475        this.close();
1476    }
1477}
1478
1479// Create singleton instance
1480let voiceControlInstance = null;
1481
1482// Initialize on page load
1483document.addEventListener('DOMContentLoaded', () => {
1484    voiceControlInstance = new VoiceControl();
1485});
1486
1487// Export static methods for HTML onclick handlers
1488window.VoiceControl = {
1489    close: () => {
1490        console.log('VoiceControl.close called');
1491        if (voiceControlInstance) {
1492            voiceControlInstance.close();
1493        } else {
1494            console.error('VoiceControl instance not initialized');
1495        }
1496    },
1497    cancel: () => {
1498        console.log('VoiceControl.cancel called');
1499        if (voiceControlInstance) {
1500            voiceControlInstance.cancel();
1501        } else {
1502            console.error('VoiceControl instance not initialized');
1503        }
1504    },
1505    execute: () => {
1506        console.log('VoiceControl.execute called');
1507        if (voiceControlInstance) {
1508            voiceControlInstance.execute();
1509        } else {
1510            console.error('VoiceControl instance not initialized');
1511        }
1512    }
1513};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.