enhanced stt/tts functions

This commit is contained in:
ValueOn AG 2026-03-16 00:47:47 +01:00
parent 04b6841c51
commit 584c5125de
9 changed files with 835 additions and 54 deletions

View file

@ -38,6 +38,7 @@ import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
// Workspace Views // Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage'; import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
// Teamsbot Views // Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -142,6 +143,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
}, },
workspace: { workspace: {
dashboard: WorkspacePage, dashboard: WorkspacePage,
settings: WorkspaceSettingsPage,
}, },
teamsbot: { teamsbot: {
dashboard: TeamsbotDashboardView, dashboard: TeamsbotDashboardView,
@ -205,8 +207,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <AccessDenied />; return <AccessDenied />;
} }
// Workspace is rendered persistently by WorkspaceKeepAlive at MainLayout level // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
if (featureCode === 'workspace') { // other workspace views (e.g. settings) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings') {
return null; return null;
} }

View file

@ -5,7 +5,7 @@
* highlighting), agent progress indicators, and file edit proposals. * highlighting), agent progress indicators, and file edit proposals.
*/ */
import React, { useRef, useEffect, useCallback } from 'react'; import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import api from '../../../api'; import api from '../../../api';
@ -126,6 +126,13 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
))} ))}
</div> </div>
)} )}
{(msg as any)._audioUrl && (
<_AudioPlayer
url={(msg as any)._audioUrl}
language={(msg as any)._audioLang}
charCount={(msg as any)._audioCharCount}
/>
)}
</div> </div>
)} )}
</div> </div>
@ -336,6 +343,121 @@ function _getFileIcon(ext: string): string {
return map[ext] || '\uD83D\uDCC4'; return map[ext] || '\uD83D\uDCC4';
} }
function _AudioPlayer({ url, language, charCount }: { url: string; language?: string; charCount?: number }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const audio = new Audio(url);
audioRef.current = audio;
audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (audio.duration) setProgress(audio.currentTime / audio.duration);
});
audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
audio.addEventListener('pause', () => setPlaying(false));
audio.addEventListener('play', () => setPlaying(true));
audio.play().catch(() => {});
return () => {
audio.pause();
audio.src = '';
};
}, [url]);
const _togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); }
}, [playing]);
const _stop = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
audio.currentTime = 0;
setPlaying(false);
setProgress(0);
}, []);
const _formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8,
background: 'var(--audio-player-bg, #f0f4f8)',
border: '1px solid var(--border-color, #e0e0e0)',
maxWidth: 360, marginTop: 6,
}}>
<button
onClick={_togglePlay}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
background: 'var(--primary-color, #1976d2)', color: '#fff',
cursor: 'pointer', fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={playing ? 'Pause' : 'Play'}
>
{playing ? '\u275A\u275A' : '\u25B6'}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
height: 4, borderRadius: 2,
background: 'var(--border-color, #ddd)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--primary-color, #1976d2)',
width: `${progress * 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
</span>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(duration) : '--:--'}
</span>
</div>
</div>
<button
onClick={_stop}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title="Stop"
>
&#x25A0;
</button>
{language && (
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
{language}
</span>
)}
</div>
);
}
function _CodeBlock({ function _CodeBlock({
className, className,
children, children,

View file

@ -1,12 +1,31 @@
/** /**
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
* voice toggle, and data source selection. * voice toggle (live transcript via SpeechRecognition), and data source selection.
*/ */
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { WorkspaceFile, DataSource } from './useWorkspace'; import type { WorkspaceFile, DataSource } from './useWorkspace';
const _STT_LANGUAGES = [
{ code: 'de-DE', label: 'Deutsch' },
{ code: 'en-US', label: 'English (US)' },
{ code: 'en-GB', label: 'English (UK)' },
{ code: 'fr-FR', label: 'Francais' },
{ code: 'it-IT', label: 'Italiano' },
{ code: 'es-ES', label: 'Espanol' },
{ code: 'pt-BR', label: 'Portugues' },
{ code: 'nl-NL', label: 'Nederlands' },
{ code: 'pl-PL', label: 'Polski' },
{ code: 'ru-RU', label: 'Russkij' },
{ code: 'ja-JP', label: 'Japanese' },
{ code: 'zh-CN', label: 'Chinese' },
];
function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null {
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
}
interface PendingFile { interface PendingFile {
fileId: string; fileId: string;
fileName: string; fileName: string;
@ -45,11 +64,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [autocompleteFilter, setAutocompleteFilter] = useState('');
const [voiceActive, setVoiceActive] = useState(false); const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
const [liveTranscript, setLiveTranscript] = useState('');
const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]); const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const recognitionRef = useRef<SpeechRecognition | null>(null);
const chunksRef = useRef<Blob[]>([]); const transcriptPartsRef = useRef<string[]>([]);
const processedIndexRef = useRef(0);
const promptBeforeVoiceRef = useRef('');
useEffect(() => {
localStorage.setItem('workspace_stt_lang', voiceLanguage);
}, [voiceLanguage]);
const _extractFileRefs = useCallback( const _extractFileRefs = useCallback(
(text: string): string[] => { (text: string): string[] => {
@ -133,42 +161,98 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId)); setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []); }, []);
const _stopRecognition = useCallback(() => {
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch { /* ignore */ }
recognitionRef.current = null;
}
const finalText = transcriptPartsRef.current.join(' ').trim();
if (finalText) {
setPrompt(prev => {
const base = promptBeforeVoiceRef.current;
return base ? `${base} ${finalText}` : finalText;
});
}
setLiveTranscript('');
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setVoiceActive(false);
}, []);
const _toggleVoice = useCallback(async () => { const _toggleVoice = useCallback(async () => {
if (voiceActive) { if (voiceActive) {
mediaRecorderRef.current?.stop(); _stopRecognition();
setVoiceActive(false);
return; return;
} }
const SpeechRecognitionApi = _getSpeechRecognitionApi();
if (!SpeechRecognitionApi) {
console.error('SpeechRecognition not supported in this browser');
return;
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream); } catch {
chunksRef.current = []; console.error('Microphone access denied');
recorder.ondataavailable = (e) => chunksRef.current.push(e.data); return;
recorder.onstop = async () => { }
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); promptBeforeVoiceRef.current = prompt;
try { transcriptPartsRef.current = [];
const formData = new FormData(); processedIndexRef.current = 0;
formData.append('audio', blob, 'voice.webm'); setLiveTranscript('');
const res = await fetch(`/api/workspace/${instanceId}/voice/transcribe`, {
method: 'POST', const recognition = new SpeechRecognitionApi();
body: formData, recognition.continuous = true;
}); recognition.interimResults = true;
const data = await res.json(); recognition.lang = voiceLanguage;
if (data.text) {
setPrompt(prev => prev + (prev ? ' ' : '') + data.text); recognition.onresult = (event: SpeechRecognitionEvent) => {
} const interimParts: string[] = [];
} catch (err) { for (let i = processedIndexRef.current; i < event.results.length; i++) {
console.error('Voice transcription failed:', err); const r = event.results[i];
if (r.isFinal) {
const text = r[0].transcript.trim();
if (text) transcriptPartsRef.current.push(text);
processedIndexRef.current = i + 1;
} else {
const text = r[0].transcript.trim();
if (text) interimParts.push(text);
} }
}; }
recorder.start(); const finalSoFar = transcriptPartsRef.current.join(' ');
mediaRecorderRef.current = recorder; const interim = interimParts.join(' ');
const combined = [finalSoFar, interim].filter(Boolean).join(' ');
setLiveTranscript(combined);
const base = promptBeforeVoiceRef.current;
const display = base ? `${base} ${combined}` : combined;
setPrompt(display);
};
recognition.onerror = (event: any) => {
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('SpeechRecognition error:', event.error);
};
recognition.onend = () => {
if (!recognitionRef.current) return;
processedIndexRef.current = 0;
setTimeout(() => {
if (!recognitionRef.current) return;
try { recognitionRef.current.start(); } catch { /* ignore */ }
}, 300);
};
try {
recognition.start();
recognitionRef.current = recognition;
setVoiceActive(true); setVoiceActive(true);
} catch (err) { } catch (err) {
console.error('Microphone access denied:', err); console.error('SpeechRecognition start failed:', err);
} }
}, [voiceActive, instanceId]); }, [voiceActive, voiceLanguage, prompt, _stopRecognition]);
const filteredFiles = showAutocomplete const filteredFiles = showAutocomplete
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
@ -367,18 +451,57 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
/> />
)} )}
<button <div style={{ position: 'relative', display: 'flex', gap: 2 }}>
onClick={_toggleVoice} <button
title={voiceActive ? 'Stop recording' : 'Voice input'} onClick={() => setShowLangPicker(prev => !prev)}
style={{ title="Sprache waehlen"
width: 40, height: 40, borderRadius: 8, border: 'none', style={{
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)', height: 40, borderRadius: '8px 0 0 8px', border: '1px solid var(--border-color, #ddd)',
color: voiceActive ? '#fff' : '#666', borderRight: 'none',
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--secondary-bg, #f5f5f5)',
}} color: '#666', cursor: 'pointer', fontSize: 10, padding: '0 6px',
> display: 'flex', alignItems: 'center', justifyContent: 'center',
{voiceActive ? '■' : '🎤'} }}
</button> >
{voiceLanguage.split('-')[0].toUpperCase()}
</button>
<button
onClick={_toggleVoice}
title={voiceActive ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
style={{
width: 40, height: 40, borderRadius: '0 8px 8px 0', border: 'none',
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
color: voiceActive ? '#fff' : '#666',
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceActive ? '■' : '\uD83C\uDFA4'}
</button>
{showLangPicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
maxHeight: 240, overflowY: 'auto', minWidth: 160,
}}>
{_STT_LANGUAGES.map(lang => (
<div
key={lang.code}
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.label} ({lang.code})
</div>
))}
</div>
)}
</div>
{isProcessing ? ( {isProcessing ? (
<button <button

View file

@ -18,7 +18,6 @@ import { FileBrowser } from './FileBrowser';
import { DataSourcePanel } from './DataSourcePanel'; import { DataSourcePanel } from './DataSourcePanel';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; import { ToolActivityLog } from './ToolActivityLog';
type LeftTab = 'conversations' | 'files' | 'datasources'; type LeftTab = 'conversations' | 'files' | 'datasources';
type RightTab = 'activity' | 'preview'; type RightTab = 'activity' | 'preview';

View file

@ -0,0 +1,173 @@
.settings {
padding: 1rem;
max-width: 600px;
}
.heading {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--text-primary, #333);
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
padding: 0.5rem 0.75rem;
background: #fde8e8;
color: var(--color-error, #d32f2f);
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.success {
padding: 0.5rem 0.75rem;
background: #e8f5e9;
color: #2e7d32;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.section {
margin-bottom: 2rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-primary, #333);
}
.field {
margin-bottom: 0.75rem;
}
.label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.3rem;
color: var(--text-primary, #333);
}
.select, .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-input, #fff);
color: var(--text-primary, #333);
}
.voiceRow {
display: flex;
gap: 0.5rem;
}
.voiceRow .select {
flex: 1;
}
.testBtn, .addBtn, .removeBtn {
padding: 0.5rem 1rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.testBtn:hover:not(:disabled),
.addBtn:hover:not(:disabled) { filter: brightness(1.08); }
.testBtn:disabled,
.addBtn:disabled {
background: var(--color-medium-gray, #ccc);
color: var(--text-secondary, #888);
cursor: not-allowed;
opacity: 0.8;
}
.removeBtn {
background: transparent;
color: var(--color-error, #d32f2f);
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
border: 1px solid var(--color-error, #d32f2f);
}
.removeBtn:hover { background: #fde8e8; }
.voiceTable {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
}
.voiceTable th,
.voiceTable td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.85rem;
}
.voiceTable th {
font-weight: 600;
color: var(--text-secondary, #666);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.emptyHint {
color: var(--text-secondary, #999);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem 0;
}
.saveBtn {
width: 100%;
padding: 0.6rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.saveBtn:hover:not(:disabled) { filter: brightness(1.08); }
.saveBtn:disabled {
background: var(--color-medium-gray, #ccc);
color: var(--text-secondary, #888);
cursor: not-allowed;
opacity: 0.8;
}
.backBtn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
color: var(--primary-color, #1976d2);
padding: 0;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 4px;
}
.backBtn:hover { text-decoration: underline; }

View file

@ -0,0 +1,275 @@
/**
* WorkspaceSettings -- Voice preferences per language.
*
* Allows the user to configure a preferred voice for each TTS language.
* Language detection is automatic; this page lets users override the
* default Google Cloud voice for specific languages.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceSettings.module.css';
interface VoiceMapEntry {
language: string;
voiceName: string;
}
interface WorkspaceSettingsProps {
instanceId: string;
}
export const WorkspaceSettings: React.FC<WorkspaceSettingsProps> = ({ instanceId }) => {
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [languages, setLanguages] = useState<any[]>([]);
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
const [addLanguage, setAddLanguage] = useState('de-DE');
const [addVoices, setAddVoices] = useState<any[]>([]);
const [addVoiceName, setAddVoiceName] = useState('');
const [loadingVoices, setLoadingVoices] = useState(false);
const _loadSettings = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
const [settingsData, languagesData] = await Promise.all([
request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }),
request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
setLanguages(langList);
const map: Record<string, any> = (settingsData as any)?.ttsVoiceMap || {};
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
language: lang,
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
}));
setVoiceMap(entries);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Einstellungen');
} finally {
setLoading(false);
}
}, [request, instanceId]);
useEffect(() => { _loadSettings(); }, [_loadSettings]);
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
if (!instanceId) return;
setLoadingVoices(true);
try {
const result = await request({
url: `/api/workspace/${instanceId}/voice/voices`,
method: 'get',
params: { language: lang },
});
setAddVoices((result as any)?.voices || []);
setAddVoiceName('');
} catch {
setAddVoices([]);
} finally {
setLoadingVoices(false);
}
}, [request, instanceId]);
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
const _handleAddEntry = useCallback(() => {
if (!addLanguage) return;
const exists = voiceMap.some(e => e.language === addLanguage);
if (exists) {
setVoiceMap(prev => prev.map(e =>
e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e
));
} else {
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
}
setAddVoiceName('');
}, [addLanguage, addVoiceName, voiceMap]);
const _handleRemoveEntry = useCallback((lang: string) => {
setVoiceMap(prev => prev.filter(e => e.language !== lang));
}, []);
const _handleSave = useCallback(async () => {
if (!instanceId) return;
setSaving(true);
setError(null);
setSuccess(null);
try {
const mapObj: Record<string, any> = {};
voiceMap.forEach(e => {
mapObj[e.language] = { voiceName: e.voiceName || '' };
});
await request({
url: `/api/workspace/${instanceId}/settings/voice`,
method: 'put',
data: { ttsVoiceMap: mapObj },
});
setSuccess('Einstellungen gespeichert');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
}, [request, instanceId, voiceMap]);
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
if (!instanceId) return;
setTesting(lang);
try {
const result: any = await request({
url: `/api/workspace/${instanceId}/voice/test`,
method: 'post',
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
});
if (result?.success && result?.audio) {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
audio.play();
}
} catch {
setError('Stimmtest fehlgeschlagen');
} finally {
setTesting(null);
}
}, [request, instanceId]);
const _getLanguageName = useCallback((code: string) => {
const found = languages.find((l: any) => (l.code || l) === code);
return found?.name || found?.code || code;
}, [languages]);
if (loading) {
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
}
const _defaultLangs = [
{ code: 'de-DE', name: 'Deutsch' },
{ code: 'en-US', name: 'English (US)' },
{ code: 'fr-FR', name: 'Francais' },
{ code: 'it-IT', name: 'Italiano' },
{ code: 'es-ES', name: 'Espanol' },
];
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
return (
<div className={styles.settings}>
<h2 className={styles.heading}>Stimmeneinstellungen</h2>
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Konfigurierte Stimmen pro Sprache</h3>
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
</p>
{voiceMap.length === 0 ? (
<div className={styles.emptyHint}>
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
</div>
) : (
<table className={styles.voiceTable}>
<thead>
<tr>
<th>Sprache</th>
<th>Stimme</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{voiceMap.map(entry => (
<tr key={entry.language}>
<td>{_getLanguageName(entry.language)}</td>
<td>{entry.voiceName || 'Standard'}</td>
<td>
<button
className={styles.testBtn}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
onClick={() => _handleTestVoice(entry.language, entry.voiceName)}
disabled={testing === entry.language}
>
{testing === entry.language ? '...' : 'Test'}
</button>
</td>
<td>
<button className={styles.removeBtn} onClick={() => _handleRemoveEntry(entry.language)}>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Stimme hinzufuegen / aendern</h3>
<div className={styles.field}>
<label className={styles.label}>Sprache</label>
<select
className={styles.select}
value={addLanguage}
onChange={e => setAddLanguage(e.target.value)}
>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>
{lang.name || lang.code || lang}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.label}>Stimme</label>
<div className={styles.voiceRow}>
<select
className={styles.select}
value={addVoiceName}
onChange={e => setAddVoiceName(e.target.value)}
disabled={loadingVoices}
>
<option value="">Standard</option>
{addVoices.map((v: any) => (
<option key={v.name || v} value={v.name || v}>
{v.displayName || v.name || v}
</option>
))}
</select>
<button
className={styles.testBtn}
onClick={() => _handleTestVoice(addLanguage, addVoiceName)}
disabled={testing !== null}
>
{testing === addLanguage ? '...' : 'Testen'}
</button>
</div>
</div>
<button className={styles.addBtn} onClick={_handleAddEntry}>
Stimme zuweisen
</button>
</div>
<button className={styles.saveBtn} onClick={_handleSave} disabled={saving}>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</div>
);
};
export default WorkspaceSettings;

View file

@ -0,0 +1,72 @@
/**
* WorkspaceSettingsPage -- Tabbed settings for the AI Workspace.
*
* First tab: Voice / Language (WorkspaceSettings).
* Additional tabs can be added here as needed.
*/
import React, { useState } from 'react';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { WorkspaceSettings } from './WorkspaceSettings';
type SettingsTab = 'voice';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'voice', label: 'Sprache & Stimme' },
];
export const WorkspaceSettingsPage: React.FC = () => {
const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
Keine Workspace-Instanz ausgewaehlt.
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<nav style={{
display: 'flex',
gap: 0,
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
flexShrink: 0,
}}>
{_TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab.key
? '2px solid var(--primary-color, #1976d2)'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 14,
fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key
? 'var(--primary-color, #1976d2)'
: 'var(--text-secondary, #888)',
}}
>
{tab.label}
</button>
))}
</nav>
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'voice' && (
<WorkspaceSettings instanceId={instanceId} />
)}
</div>
</div>
);
};
export default WorkspaceSettingsPage;

View file

@ -273,7 +273,22 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
}]); }]);
}, },
onVoiceResponse: (event) => { onVoiceResponse: (event) => {
_handleVoiceResponse(event); const audioUrl = _buildAudioUrl(event);
if (audioUrl) {
const lang = event.item?.language || event.data?.language || '';
const charCount = event.item?.charCount || event.data?.charCount || 0;
setMessages(prev => [
...prev,
{
id: `voice-${Date.now()}`,
role: 'assistant',
message: '',
_audioUrl: audioUrl,
_audioLang: lang,
_audioCharCount: charCount,
} as any,
]);
}
}, },
onComplete: (event) => { onComplete: (event) => {
setIsProcessing(false); setIsProcessing(false);
@ -479,9 +494,9 @@ function _handleToolResult(
}); });
} }
function _handleVoiceResponse(event: SseEvent) { function _buildAudioUrl(event: SseEvent): string | null {
const audioData = event.item?.audio || event.data?.audio; const audioData = event.item?.audio || event.data?.audio;
if (!audioData) return; if (!audioData) return null;
try { try {
const byteChars = atob(audioData); const byteChars = atob(audioData);
@ -490,11 +505,9 @@ function _handleVoiceResponse(event: SseEvent) {
byteArray[i] = byteChars.charCodeAt(i); byteArray[i] = byteChars.charCodeAt(i);
} }
const blob = new Blob([byteArray], { type: 'audio/mp3' }); const blob = new Blob([byteArray], { type: 'audio/mp3' });
const url = URL.createObjectURL(blob); return URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play().catch(err => console.error('TTS playback failed:', err));
audio.onended = () => URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error('Failed to decode voice response:', err); console.error('Failed to decode voice response:', err);
return null;
} }
} }

View file

@ -307,6 +307,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'psychology', icon: 'psychology',
views: [ views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' }, { code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
] ]
}, },
}; };