enhanced stt/tts functions
This commit is contained in:
parent
04b6841c51
commit
584c5125de
9 changed files with 835 additions and 54 deletions
|
|
@ -38,6 +38,7 @@ import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
|
|||
|
||||
// Workspace Views
|
||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||
|
||||
// Teamsbot Views
|
||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||
|
|
@ -142,6 +143,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
},
|
||||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
settings: WorkspaceSettingsPage,
|
||||
},
|
||||
teamsbot: {
|
||||
dashboard: TeamsbotDashboardView,
|
||||
|
|
@ -205,8 +207,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
return <AccessDenied />;
|
||||
}
|
||||
|
||||
// Workspace is rendered persistently by WorkspaceKeepAlive at MainLayout level
|
||||
if (featureCode === 'workspace') {
|
||||
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
||||
// other workspace views (e.g. settings) use the standard FeatureViewPage rendering.
|
||||
if (featureCode === 'workspace' && view !== 'settings') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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 remarkGfm from 'remark-gfm';
|
||||
import api from '../../../api';
|
||||
|
|
@ -126,6 +126,13 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{(msg as any)._audioUrl && (
|
||||
<_AudioPlayer
|
||||
url={(msg as any)._audioUrl}
|
||||
language={(msg as any)._audioLang}
|
||||
charCount={(msg as any)._audioCharCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -336,6 +343,121 @@ function _getFileIcon(ext: string): string {
|
|||
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"
|
||||
>
|
||||
■
|
||||
</button>
|
||||
|
||||
{language && (
|
||||
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _CodeBlock({
|
||||
className,
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,31 @@
|
|||
/**
|
||||
* 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 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 {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
|
|
@ -45,11 +64,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||
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 [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const transcriptPartsRef = useRef<string[]>([]);
|
||||
const processedIndexRef = useRef(0);
|
||||
const promptBeforeVoiceRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
||||
}, [voiceLanguage]);
|
||||
|
||||
const _extractFileRefs = useCallback(
|
||||
(text: string): string[] => {
|
||||
|
|
@ -133,42 +161,98 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
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 () => {
|
||||
if (voiceActive) {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setVoiceActive(false);
|
||||
_stopRecognition();
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognitionApi = _getSpeechRecognitionApi();
|
||||
if (!SpeechRecognitionApi) {
|
||||
console.error('SpeechRecognition not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'voice.webm');
|
||||
const res = await fetch(`/api/workspace/${instanceId}/voice/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.text) {
|
||||
setPrompt(prev => prev + (prev ? ' ' : '') + data.text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Voice transcription failed:', err);
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
console.error('Microphone access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
promptBeforeVoiceRef.current = prompt;
|
||||
transcriptPartsRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
setLiveTranscript('');
|
||||
|
||||
const recognition = new SpeechRecognitionApi();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = voiceLanguage;
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
const interimParts: string[] = [];
|
||||
for (let i = processedIndexRef.current; i < event.results.length; i++) {
|
||||
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();
|
||||
mediaRecorderRef.current = recorder;
|
||||
}
|
||||
const finalSoFar = transcriptPartsRef.current.join(' ');
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error('Microphone access denied:', err);
|
||||
console.error('SpeechRecognition start failed:', err);
|
||||
}
|
||||
}, [voiceActive, instanceId]);
|
||||
}, [voiceActive, voiceLanguage, prompt, _stopRecognition]);
|
||||
|
||||
const filteredFiles = showAutocomplete
|
||||
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
||||
|
|
@ -367,18 +451,57 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={_toggleVoice}
|
||||
title={voiceActive ? 'Stop recording' : 'Voice input'}
|
||||
style={{
|
||||
width: 40, height: 40, borderRadius: 8, border: 'none',
|
||||
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
|
||||
color: voiceActive ? '#fff' : '#666',
|
||||
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{voiceActive ? '■' : '🎤'}
|
||||
</button>
|
||||
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
|
||||
<button
|
||||
onClick={() => setShowLangPicker(prev => !prev)}
|
||||
title="Sprache waehlen"
|
||||
style={{
|
||||
height: 40, borderRadius: '8px 0 0 8px', border: '1px solid var(--border-color, #ddd)',
|
||||
borderRight: 'none',
|
||||
background: 'var(--secondary-bg, #f5f5f5)',
|
||||
color: '#666', cursor: 'pointer', fontSize: 10, padding: '0 6px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{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 ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { FileBrowser } from './FileBrowser';
|
|||
import { DataSourcePanel } from './DataSourcePanel';
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { ToolActivityLog } from './ToolActivityLog';
|
||||
|
||||
type LeftTab = 'conversations' | 'files' | 'datasources';
|
||||
type RightTab = 'activity' | 'preview';
|
||||
|
||||
|
|
|
|||
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal file
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal 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; }
|
||||
275
src/pages/views/workspace/WorkspaceSettings.tsx
Normal file
275
src/pages/views/workspace/WorkspaceSettings.tsx
Normal 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;
|
||||
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal file
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal 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;
|
||||
|
|
@ -273,7 +273,22 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
}]);
|
||||
},
|
||||
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) => {
|
||||
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;
|
||||
if (!audioData) return;
|
||||
if (!audioData) return null;
|
||||
|
||||
try {
|
||||
const byteChars = atob(audioData);
|
||||
|
|
@ -490,11 +505,9 @@ function _handleVoiceResponse(event: SseEvent) {
|
|||
byteArray[i] = byteChars.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: 'audio/mp3' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.play().catch(err => console.error('TTS playback failed:', err));
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (err) {
|
||||
console.error('Failed to decode voice response:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
icon: 'psychology',
|
||||
views: [
|
||||
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
|
||||
]
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue