google keys transferred to account poweron.center.ai

This commit is contained in:
ValueOn AG 2026-05-11 21:26:24 +02:00
parent 5c55312c60
commit 544f36460a
13 changed files with 1050 additions and 486 deletions

View file

@ -185,7 +185,10 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p>
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p style="margin-top: 1rem;">&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -140,7 +140,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
<strong>Last Updated:</strong> May 2026
</div>
<div class="content-section">
@ -272,8 +272,13 @@
<h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div>
</div>
@ -283,7 +288,7 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p>&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -153,7 +153,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
<strong>Last Updated:</strong> May 2026
</div>
<div class="content-section">
@ -315,8 +315,13 @@
<h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div>
</div>
@ -326,7 +331,7 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p>&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -84,6 +84,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest {
meetingLink: string;
botName?: string;
moduleId?: string;
connectionId?: string;
joinMode?: TeamsbotJoinMode;
sessionContext?: string;
@ -462,6 +463,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
return new EventSource(url, { withCredentials: true });
}
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
export function createDashboardStream(instanceId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
return new EventSource(url, { withCredentials: true });
}
// =========================================================================
// Debug Screenshots (SysAdmin only)
// =========================================================================
@ -592,6 +600,8 @@ export interface MeetingModule {
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
status: string;
}
@ -602,6 +612,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module;

View file

@ -109,6 +109,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// Feature pages - Teams Bot
'page.feature.teamsbot.dashboard': <FaChartLine />,
'page.feature.teamsbot.assistant': <FaHatWizard />,
'page.feature.teamsbot.modules': <FaCubes />,
'page.feature.teamsbot.sessions': <FaVideo />,
'page.feature.teamsbot.settings': <FaCog />,

View file

@ -21,10 +21,17 @@ export interface VoiceStreamCallbacks {
onError?: (error: unknown) => void;
}
/** Options for the initial `open` message on the generic STT WebSocket (Google streaming). */
export interface SttStreamOpenOptions {
model?: string;
lightweight?: boolean;
singleUtterance?: boolean;
}
export interface VoiceStreamApi {
status: VoiceStreamStatus;
interimText: string;
start: (language?: string) => Promise<void>;
start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise<void>;
stop: () => void;
}
@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const languageRef = useRef('de-DE');
const sttOpenOptsRef = useRef<SttStreamOpenOptions | undefined>(undefined);
const stoppingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
@ -94,11 +102,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
stoppingRef.current = false;
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
const start = useCallback(async (language?: string) => {
const _buildOpenPayload = useCallback(() => {
const o = sttOpenOptsRef.current;
return {
type: 'open' as const,
language: languageRef.current,
model: o?.model ?? 'latest_long',
lightweight: o?.lightweight ?? false,
singleUtterance: o?.singleUtterance ?? false,
};
}, []);
const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => {
if (status === 'listening' || status === 'connecting') return;
stoppingRef.current = false;
reconnectAttemptsRef.current = 0;
languageRef.current = language || 'de-DE';
sttOpenOptsRef.current = sttOpenOptions;
_setStatus('connecting');
try {
@ -120,7 +140,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
ws.onopen = () => {
if (stoppingRef.current) { ws.close(); return; }
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
ws.send(JSON.stringify(_buildOpenPayload()));
const mimeType = _pickMimeType();
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
@ -154,11 +174,15 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
cbRef.current.onFinal?.(msg.text);
} else if (msg.type === 'error') {
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
} else if (msg.type === 'end_of_single_utterance') {
if (!stoppingRef.current && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(_buildOpenPayload()));
}
} else if (msg.type === 'reconnect_required') {
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
reconnectAttemptsRef.current++;
_closeWs();
start(languageRef.current).catch(() => {});
start(languageRef.current, sttOpenOptsRef.current).catch(() => {});
}
}
} catch { /* ignore parse errors */ }
@ -183,7 +207,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
_releaseDevices();
throw err;
}
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
useEffect(() => {
return () => {

View file

@ -10,7 +10,7 @@
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import { useVoiceStream, type SttStreamOpenOptions } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -35,6 +35,13 @@ export interface VoiceControllerCallbacks {
const _DEFAULT_STT_LANGUAGE = 'de-DE';
/** CommCoach: faster streaming STT profile + single-utterance endpointing (client re-opens stream). */
const _commcoachSttOpen: SttStreamOpenOptions = {
model: 'latest_short',
lightweight: true,
singleUtterance: true,
};
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
@ -86,7 +93,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
});
const _startStream = useCallback(() => {
return voiceStream.start(sttLanguageRef.current);
return voiceStream.start(sttLanguageRef.current, _commcoachSttOpen);
}, [voiceStream]);
const activate = useCallback(async () => {

View file

@ -1478,6 +1478,10 @@
border-color: var(--primary-color, #4A90D9);
}
.moduleRowFocused {
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.45);
}
.moduleRow {
display: flex;
align-items: center;
@ -1654,3 +1658,222 @@
padding: 2rem;
color: var(--text-secondary, #666);
}
/* --- TeamsBot Dashboard (Greenfield IA) --- */
.tbDash {
display: flex;
flex-direction: column;
gap: 1.75rem;
padding: 1.25rem 1.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.tbDashHero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1.25rem;
padding: 1.5rem 1.75rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(74, 144, 217, 0.12) 0%, var(--surface-color, #fff) 48%);
border: 1px solid var(--border-color, #e6e6e6);
}
.tbDashTitle {
margin: 0 0 0.35rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
}
.tbDashSubtitle {
margin: 0;
max-width: 520px;
font-size: 0.95rem;
line-height: 1.45;
color: var(--text-secondary, #555);
}
.tbDashQuickActions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.tbDashBtnPrimary {
padding: 0.65rem 1.35rem;
border-radius: 8px;
border: none;
background: var(--primary-color, #4A90D9);
color: #fff;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.tbDashBtnPrimary:hover {
background: var(--primary-hover, #3A7BC8);
}
.tbDashBtnSecondary {
padding: 0.65rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--border-color, #d0d0d0);
background: var(--surface-color, #fff);
color: var(--text-primary, #333);
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
}
.tbDashBtnSecondary:hover {
border-color: var(--primary-color, #4A90D9);
color: var(--primary-color, #4A90D9);
}
.tbDashKpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.tbDashKpiCard {
padding: 1.1rem 1.25rem;
border-radius: 10px;
border: 1px solid var(--border-color, #e8e8e8);
background: var(--surface-color, #fff);
}
.tbDashKpiValue {
font-size: 1.75rem;
font-weight: 700;
color: var(--primary-color, #4A90D9);
line-height: 1.1;
}
.tbDashKpiLabel {
margin-top: 0.35rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tbDashKpiHint {
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--text-tertiary, #888);
}
.tbDashSection {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.tbDashSectionHead {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.tbDashSectionTitle {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary, #222);
}
.tbDashLinkBtn {
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
color: var(--primary-color, #4A90D9);
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.tbDashModuleGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.tbDashModuleCard {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border-color, #e6e6e6);
background: var(--surface-color, #fafafa);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.tbDashModuleCard:hover {
border-color: var(--primary-color, #4A90D9);
box-shadow: 0 2px 8px rgba(74, 144, 217, 0.12);
}
.tbDashModuleTitle {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary, #222);
}
.tbDashModuleCount {
font-size: 0.82rem;
color: var(--text-secondary, #666);
}
.tbDashSessionList {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.tbDashSessionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-color, #eaeaea);
background: var(--surface-color, #fff);
}
.tbDashSessionMain {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 200px;
}
.tbDashSessionMeta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.82rem;
color: var(--text-secondary, #666);
flex: 2 1 220px;
}
.tbDashSessionActions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-left: auto;
}

View file

@ -3,10 +3,12 @@
*
* Wizard: Select/create module Meeting link Bot selection "Start bot"
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotJoinMode, UserAccountStatus } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './Teamsbot.module.css';
@ -18,16 +20,26 @@ export const TeamsbotAssistantView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const cachedUser = getUserDataCache();
const isSysAdmin = cachedUser?.isSysAdmin === true;
const [searchParams] = useSearchParams();
const preselectedModuleId = searchParams.get('moduleId');
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
const [modules, setModules] = useState<any[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [moduleFilter, setModuleFilter] = useState('');
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
const [newModuleTitle, setNewModuleTitle] = useState('');
const [createNewModule, setCreateNewModule] = useState(false);
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('AI Assistant');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
const [showCredentialForm, setShowCredentialForm] = useState(false);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [savingCredentials, setSavingCredentials] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -45,6 +57,33 @@ export const TeamsbotAssistantView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]);
useEffect(() => {
if (joinMode === 'userAccount' && instanceId) {
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
}
}, [joinMode, instanceId]);
const filteredModules = useMemo(() => {
const q = moduleFilter.trim().toLowerCase();
if (!q) return modules;
return modules.filter(m => m.title.toLowerCase().includes(q));
}, [modules, moduleFilter]);
const modulePrefillKeyRef = useRef<string>('');
useEffect(() => {
if (!selectedModuleId || createNewModule) {
modulePrefillKeyRef.current = '';
return;
}
const mod = modules.find(m => m.id === selectedModuleId);
if (!mod) return;
const key = `${selectedModuleId}:${mod.defaultMeetingLink ?? ''}:${mod.defaultBotName ?? ''}`;
if (modulePrefillKeyRef.current === key) return;
modulePrefillKeyRef.current = key;
if (mod.defaultMeetingLink) setMeetingLink(mod.defaultMeetingLink);
if (mod.defaultBotName) setBotName(mod.defaultBotName);
}, [selectedModuleId, createNewModule, modules]);
const _handleNext = () => {
const nextIdx = stepIdx + 1;
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
@ -60,6 +99,27 @@ export const TeamsbotAssistantView: React.FC = () => {
setError(t('Meeting-Link erforderlich'));
return;
}
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
setShowCredentialForm(true);
setError(t('Bitte Microsoft-Zugangsdaten eingeben oder speichern.'));
return;
}
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
if (needsSave || needsUpdate) {
try {
setSavingCredentials(true);
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
setUserAccount({ hasSavedCredentials: true, email: credEmail });
setShowCredentialForm(false);
} catch (err: any) {
setError(err?.message || t('Fehler beim Speichern der Zugangsdaten'));
setSavingCredentials(false);
return;
} finally {
setSavingCredentials(false);
}
}
setLoading(true);
setError(null);
try {
@ -73,7 +133,9 @@ export const TeamsbotAssistantView: React.FC = () => {
meetingLink: meetingLink.trim(),
botName,
moduleId: moduleId || undefined,
} as any);
joinMode,
sessionContext: sessionContext.trim() || undefined,
});
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
} catch (err: any) {
@ -106,16 +168,27 @@ export const TeamsbotAssistantView: React.FC = () => {
{t('Bestehendes Modul')}
</label>
{!createNewModule && (
<select
value={selectedModuleId || ''}
onChange={e => setSelectedModuleId(e.target.value || null)}
className={styles.wizardSelect}
>
<option value="">{t('Kein Modul (Adhoc)')}</option>
{modules.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<>
<input
type="search"
className={styles.wizardInput}
placeholder={t('Modul suchen…')}
value={moduleFilter}
onChange={e => setModuleFilter(e.target.value)}
aria-label={t('Modul suchen')}
/>
<select
value={selectedModuleId || ''}
onChange={e => setSelectedModuleId(e.target.value || null)}
className={styles.wizardSelect}
size={Math.min(12, Math.max(4, filteredModules.length + 1))}
>
<option value="">{t('Kein Modul (Adhoc)')}</option>
{filteredModules.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
</>
)}
<label>
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
@ -136,7 +209,7 @@ export const TeamsbotAssistantView: React.FC = () => {
{step === 'meeting' && (
<div className={styles.wizardStep}>
<h3>{t('Meeting-Link')}</h3>
<h3>{t('Meeting-Link und Beitritt')}</h3>
<input
type="text"
className={styles.wizardInput}
@ -145,6 +218,87 @@ export const TeamsbotAssistantView: React.FC = () => {
onChange={e => setMeetingLink(e.target.value)}
autoFocus
/>
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Join-Modus')}</label>
<select
className={styles.wizardSelect}
value={joinMode}
onChange={e => setJoinMode(e.target.value as TeamsbotJoinMode)}
>
{isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
<option value="anonymous">{t('Anonymer Gast')}</option>
<option value="userAccount">{t('Mein Account')}</option>
</select>
{joinMode === 'userAccount' && (
<div className={styles.credentialsCard} style={{ marginTop: '0.75rem' }}>
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
<div className={styles.credentialsInfo}>
<span>
{t('Gespeichert:')} <span className={styles.credentialsEmail}>{userAccount.email}</span>
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
className={styles.viewButton}
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
>
{t('Ändern')}
</button>
<button
type="button"
className={styles.deleteButton}
onClick={async () => {
try {
await teamsbotApi.deleteUserAccount(instanceId);
setUserAccount({ hasSavedCredentials: false });
setCredEmail('');
setCredPassword('');
} catch { /* ignore */ }
}}
>
{t('Entfernen')}
</button>
</div>
</div>
) : (
<>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
<input
type="email"
className={styles.input}
value={credEmail}
onChange={e => setCredEmail(e.target.value)}
disabled={savingCredentials}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Passwort')}</label>
<input
type="password"
className={styles.input}
value={credPassword}
onChange={e => setCredPassword(e.target.value)}
disabled={savingCredentials}
/>
</div>
<span className={styles.hint}>{t('Zugangsdaten werden verschlüsselt gespeichert.')}</span>
{userAccount?.hasSavedCredentials && (
<button type="button" className={styles.viewButton} style={{ marginTop: '0.5rem' }} onClick={() => setShowCredentialForm(false)}>
{t('Abbrechen')}
</button>
)}
</>
)}
</div>
)}
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Sitzungskontext (optional)')}</label>
<textarea
className={styles.wizardTextarea}
placeholder={t('Agenda, Hintergrund …')}
value={sessionContext}
onChange={e => setSessionContext(e.target.value)}
rows={3}
/>
</div>
)}
@ -167,6 +321,8 @@ export const TeamsbotAssistantView: React.FC = () => {
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
<div><strong>{t('Bot')}:</strong> {botName}</div>
<div><strong>{t('Join-Modus')}:</strong> {joinMode}</div>
{sessionContext.trim() ? <div><strong>{t('Kontext')}:</strong> {sessionContext.trim().slice(0, 120)}{sessionContext.length > 120 ? '…' : ''}</div> : null}
</div>
</div>
)}
@ -180,7 +336,15 @@ export const TeamsbotAssistantView: React.FC = () => {
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}

View file

@ -1,230 +1,135 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const PAST_LIMIT = 10;
/**
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
* Allows starting new sessions and viewing active/past sessions.
* Supports "Mein Account" login with saved credentials and MFA relay.
* TeamsBot Dashboard IA: KPIs, Modul-Aggregate, Quick-Actions, kompakte Session-Listen.
* Neues Meeting: Assistent (Wizard). Kein paralleler Vollformular-Start hier.
*/
export const TeamsbotDashboardView: React.FC = () => {
const { t } = useLanguage();
const { t } = useLanguage();
const { instance, mandateId, featureCode } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const cachedUser = getUserDataCache();
const _isSysAdmin = cachedUser?.isSysAdmin === true;
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New session form
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [isStarting, setIsStarting] = useState(false);
const sessionsRef = useRef<TeamsbotSession[]>([]);
sessionsRef.current = sessions;
const dashboardEsRef = useRef<EventSource | null>(null);
const dashboardReconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// User Account (Mein Account) state
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
const [showCredentialForm, setShowCredentialForm] = useState(false);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [savingCredentials, setSavingCredentials] = useState(false);
// MFA state
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaSessionId, setMfaSessionId] = useState<string | null>(null);
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
const sseRef = useRef<EventSource | null>(null);
const _loadSessions = useCallback(async () => {
if (!instanceId) return;
try {
setLoading(true);
const result = await teamsbotApi.listSessions(instanceId);
setSessions(result.sessions || []);
setError(null);
} catch (err: any) {
setError(err.message || t('Fehler beim Laden der Sitzungen'));
} finally {
setLoading(false);
}
}, [instanceId, t]);
useEffect(() => {
_loadSessions();
}, [_loadSessions]);
// Load user account status when joinMode changes to userAccount
useEffect(() => {
if (joinMode === 'userAccount' && instanceId) {
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
}
}, [joinMode, instanceId]);
// Adaptive polling: 3s with active sessions, 30s otherwise
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
const interval = hasActiveSessions ? 3000 : 30000;
if (instanceId) {
pollRef.current = setInterval(() => {
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
}, interval);
}
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [sessions, instanceId]);
// Cleanup SSE on unmount
useEffect(() => {
return () => { sseRef.current?.close(); };
const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
setSessions(nextSessions);
setModules(nextModules);
sessionsRef.current = nextSessions;
}, []);
const _startMfaListener = useCallback((sessionId: string) => {
sseRef.current?.close();
const es = teamsbotApi.createSessionStream(instanceId, sessionId);
sseRef.current = es;
setMfaSessionId(sessionId);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
es.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed.type === 'mfaChallenge') {
const data = parsed.data as MfaChallengeEvent;
if (data.mfaType === 'timeout') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
} else {
setMfaChallenge(data);
setMfaCode('');
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
}
} else if (parsed.type === 'mfaResolved') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setMfaSessionId(null);
es.close();
sseRef.current = null;
_loadSessions();
}
} catch { /* ignore parse errors */ }
};
}, [instanceId, _loadSessions, t]);
const _handleStartSession = async () => {
if (!meetingLink.trim()) return;
// For userAccount: need credentials (saved or entered now)
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
setShowCredentialForm(true);
return;
}
// userAccount with new/unsaved credentials: save to DB before starting
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
if (needsSave || needsUpdate) {
try {
setSavingCredentials(true);
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
setUserAccount({ hasSavedCredentials: true, email: credEmail });
} catch (err: any) {
setError(err.message || t('Fehler beim Speichern der Zugangsdaten'));
setSavingCredentials(false);
return;
} finally {
setSavingCredentials(false);
const clearReconnect = () => {
if (dashboardReconnectRef.current) {
window.clearTimeout(dashboardReconnectRef.current);
dashboardReconnectRef.current = null;
}
setShowCredentialForm(false);
}
};
setIsStarting(true);
setError(null);
const connect = () => {
if (cancelled) return;
dashboardEsRef.current?.close();
setLoading(true);
const es = teamsbotApi.createDashboardStream(instanceId);
dashboardEsRef.current = es;
try {
const request: StartSessionRequest = {
meetingLink: meetingLink.trim(),
botName: botName.trim() || undefined,
joinMode: joinMode,
sessionContext: sessionContext.trim() || undefined,
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as {
type?: string;
sessions?: TeamsbotSession[];
modules?: MeetingModule[];
};
if (msg.type === 'dashboardState' && Array.isArray(msg.sessions) && Array.isArray(msg.modules)) {
applyDashboardPayload(msg.sessions, msg.modules);
setError(null);
setLoading(false);
}
} catch {
/* ignore malformed SSE */
}
};
const result = await teamsbotApi.startSession(instanceId, request);
const newSessionId = result.session?.id;
setMeetingLink('');
setBotName('');
es.onerror = () => {
es.close();
dashboardEsRef.current = null;
if (cancelled) return;
setLoading(false);
clearReconnect();
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
};
};
// Start SSE listener for MFA events if userAccount mode
if (joinMode === 'userAccount' && newSessionId) {
_startMfaListener(newSessionId);
}
connect();
return () => {
cancelled = true;
clearReconnect();
dashboardEsRef.current?.close();
dashboardEsRef.current = null;
};
}, [instanceId, applyDashboardPayload]);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Starten der Sitzung'));
} finally {
setIsStarting(false);
}
};
const activeSessions = useMemo(
() => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
[sessions],
);
const pastSessions = useMemo(
() =>
sessions
.filter((s) => ['ended', 'error', 'leaving'].includes(s.status))
.sort((a, b) => {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return tb - ta;
}),
[sessions],
);
const pastVisible = pastSessions.slice(0, PAST_LIMIT);
const _handleSubmitMfaCode = async () => {
if (!mfaSessionId || !instanceId) return;
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
try {
await teamsbotApi.submitMfaCode(
instanceId,
mfaSessionId,
needsCode ? mfaCode : '',
needsCode ? 'code' : 'confirmed',
);
if (!needsCode) {
setMfaWaitingPush(true);
}
} catch (err: any) {
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
}
};
const moduleTitleById = useMemo(() => {
const m = new Map<string, string>();
modules.forEach((mod) => m.set(mod.id, mod.title));
return m;
}, [modules]);
const _handleDeleteUserAccount = async () => {
try {
await teamsbotApi.deleteUserAccount(instanceId);
setUserAccount({ hasSavedCredentials: false });
setCredEmail('');
setCredPassword('');
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Zugangsdaten'));
}
};
const topModules = useMemo(() => {
const counts = new Map<string, number>();
sessions.forEach((s) => {
const mid = s.moduleId || '_adhoc';
counts.set(mid, (counts.get(mid) || 0) + 1);
});
const rows = Array.from(counts.entries())
.map(([moduleId, sessionCount]) => ({
moduleId,
sessionCount,
title: moduleId === '_adhoc' ? t('Adhoc / ohne Modul') : (moduleTitleById.get(moduleId) || t('Unbekanntes Modul')),
}))
.sort((a, b) => b.sessionCount - a.sessionCount)
.slice(0, 6);
return rows;
}, [sessions, moduleTitleById, t]);
const _handleStopSession = async (sessionId: string) => {
try {
await teamsbotApi.stopSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen der Sitzung'));
}
};
const _handleDeleteSession = async (sessionId: string) => {
try {
await teamsbotApi.deleteSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Sitzung'));
}
};
const totalSegments = useMemo(() => sessions.reduce((acc, s) => acc + (s.transcriptSegmentCount || 0), 0), [sessions]);
const totalResponses = useMemo(() => sessions.reduce((acc, s) => acc + (s.botResponseCount || 0), 0), [sessions]);
const _getStatusBadgeClass = (status: string) => {
switch (status) {
@ -240,270 +145,213 @@ export const TeamsbotDashboardView: React.FC = () => {
const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: 'Wartend',
joining: 'Beitritt...',
active: 'Aktiv',
leaving: 'Verlassen...',
ended: 'Beendet',
error: 'Fehler',
pending: t('Wartend'),
joining: t('Beitritt…'),
active: t('Aktiv'),
leaving: t('Verlassen…'),
ended: t('Beendet'),
error: t('Fehler'),
};
return labels[status] || status;
};
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status));
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status));
const _sessionPath = (sessId: string) =>
`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${sessId}`;
const _needsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
const _refreshLists = useCallback(async () => {
if (!instanceId) return;
try {
const [r, m] = await Promise.all([
teamsbotApi.listSessions(instanceId, true),
teamsbotApi.listModules(instanceId),
]);
const nextSessions = r.sessions || [];
setSessions(nextSessions);
setModules(m || []);
sessionsRef.current = nextSessions;
} catch { /* ignore */ }
}, [instanceId]);
const _handleStopSession = async (sid: string) => {
try {
await teamsbotApi.stopSession(instanceId, sid);
await _refreshLists();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen'));
}
};
const _handleDeleteSession = async (sid: string) => {
try {
await teamsbotApi.deleteSession(instanceId, sid);
await _refreshLists();
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen'));
}
};
return (
<div className={styles.dashboardContainer}>
{/* MFA Challenge Dialog */}
{mfaChallenge && (
<div className={styles.mfaOverlay}>
<div className={styles.mfaDialog}>
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div>
{mfaChallenge.displayNumber && (
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
)}
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
{_needsCodeInput ? (
<>
<input
type="text"
className={styles.mfaCodeInput}
placeholder={t('Code eingeben')}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
autoFocus
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
/>
<button className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
Bestaetigen
</button>
</>
) : mfaWaitingPush ? (
<>
<div className={styles.mfaSpinner} />
<p style={{ fontSize: '0.85rem', color: '#888' }}>
Warte auf Bestaetigung in der Authenticator App...
</p>
</>
) : (
<button className={styles.startButton} onClick={_handleSubmitMfaCode}>
Ich habe bestaetigt
</button>
)}
</div>
<div className={styles.tbDash}>
<header className={styles.tbDashHero}>
<div className={styles.tbDashHeroText}>
<h1 className={styles.tbDashTitle}>{t('Teams Bot')}</h1>
<p className={styles.tbDashSubtitle}>
{t('Dashboard mit Übersicht, Modulen und Live-Sitzung — neues Meeting über den Assistenten starten.')}
</p>
</div>
)}
{/* Start New Session Card */}
<div className={styles.startSessionCard}>
<h3 className={styles.cardTitle}>{t('Neue Botsitzung starten')}</h3>
<p className={styles.cardDescription}>
Fuege den Teams Meeting-Link ein, um den AI-Bot in ein Meeting einzuschleusen.
</p>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Teams-Meetinglink')}</label>
<input
type="url"
className={styles.input}
placeholder="https://teams.microsoft.com/l/meetup-join/..."
value={meetingLink}
onChange={(e) => setMeetingLink(e.target.value)}
disabled={isStarting}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Join-Modus</label>
<select
className={styles.select || styles.input}
value={joinMode}
onChange={(e) => setJoinMode(e.target.value as TeamsbotJoinMode)}
disabled={isStarting}
<div className={styles.tbDashQuickActions}>
<button
type="button"
className={styles.tbDashBtnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
>
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
<option value="anonymous">{t('Anonymer Gast')}</option>
<option value="userAccount">{t('Mein Account')}</option>
</select>
{t('Neues Meeting')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
>
{t('Module')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions`)}
>
{t('Live-Session')}
</button>
</div>
{/* User Account: saved credentials info or credential form */}
{joinMode === 'userAccount' && (
<div className={styles.credentialsCard}>
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
<div className={styles.credentialsInfo}>
<span>
Gespeichert: <span className={styles.credentialsEmail}>{userAccount.email}</span>
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className={styles.viewButton}
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
>
Aendern
</button>
<button className={styles.deleteButton} onClick={_handleDeleteUserAccount}>
Entfernen
</button>
</div>
</div>
) : (
<>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
<input
type="email"
className={styles.input}
placeholder="name@example.com"
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
disabled={savingCredentials}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Passwort')}</label>
<input
type="password"
className={styles.input}
placeholder="Microsoft-Passwort"
value={credPassword}
onChange={(e) => setCredPassword(e.target.value)}
disabled={savingCredentials}
/>
</div>
<span style={{ fontSize: '12px', color: '#888', marginTop: '4px', display: 'block' }}>
Zugangsdaten werden verschluesselt gespeichert.
</span>
{userAccount?.hasSavedCredentials && (
<button
className={styles.viewButton}
style={{ marginTop: '0.5rem' }}
onClick={() => setShowCredentialForm(false)}
>
Abbrechen
</button>
)}
</>
)}
</div>
)}
<div className={styles.formGroup}>
<label className={styles.label}>{t('Botname (optional)')}</label>
<input
type="text"
className={styles.input}
placeholder={t('KI-Assistent')}
value={botName}
onChange={(e) => setBotName(e.target.value)}
disabled={isStarting}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Sitzungskontext (optional)')}</label>
<textarea
className={styles.textarea || styles.input}
placeholder={t('Agenda, Hintergrundinfos, Dokumente oder …')}
value={sessionContext}
onChange={(e) => setSessionContext(e.target.value)}
disabled={isStarting}
rows={4}
style={{ resize: 'vertical', minHeight: '80px' }}
/>
<span style={{ fontSize: '12px', color: '#888' }}>
Kontext den der Bot waehrend der Sitzung nutzen kann (z.B. Meeting-Agenda, Projektinfos).
{sessionContext.length > 0 && ` (${sessionContext.length} Zeichen)`}
</span>
</div>
<button
className={styles.startButton}
onClick={_handleStartSession}
disabled={isStarting || !meetingLink.trim() || (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))}
>
{isStarting ? t('Wird gestartet') : t('Bot ins Meeting senden')}
</button>
</div>
</header>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Active Sessions */}
<section className={styles.tbDashKpiGrid} aria-label={t('Kennzahlen')}>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{modules.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Meeting-Module')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{activeSessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Aktive Sitzungen')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{sessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Sitzungen gesamt')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{totalSegments}</div>
<div className={styles.tbDashKpiLabel}>{t('Transkript-Segmente')}</div>
<div className={styles.tbDashKpiHint}>{totalResponses} {t('Bot-Antworten')}</div>
</div>
</section>
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Module nach Aktivität')}</h2>
{topModules.length === 0 ? (
<p className={styles.emptyState}>{t('Noch keine Sitzungen — starte ein Meeting im Assistenten.')}</p>
) : (
<div className={styles.tbDashModuleGrid}>
{topModules.map((row) => (
<button
key={row.moduleId}
type="button"
className={styles.tbDashModuleCard}
onClick={() => navigate(
row.moduleId === '_adhoc'
? `/mandates/${mandateId}/${featureCode}/${instanceId}/modules`
: `/mandates/${mandateId}/${featureCode}/${instanceId}/modules?moduleId=${encodeURIComponent(row.moduleId)}`,
)}
>
<span className={styles.tbDashModuleTitle}>{row.title}</span>
<span className={styles.tbDashModuleCount}>{row.sessionCount} {t('Sitzungen')}</span>
</button>
))}
</div>
)}
</section>
{activeSessions.length > 0 && (
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3>
<div className={styles.sessionList}>
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Aktive Sitzungen')}</h2>
<div className={styles.tbDashSessionList}>
{activeSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}>
<div className={styles.sessionHeader}>
<div key={session.id} className={styles.tbDashSessionRow}>
<div className={styles.tbDashSessionMain}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.sessionMeta}>
<span>{session.transcriptSegmentCount} Segmente</span>
<span>{session.botResponseCount} Antworten</span>
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
<div className={styles.tbDashSessionMeta}>
{session.moduleId && (
<span>{moduleTitleById.get(session.moduleId) || session.moduleId}</span>
)}
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
<span>{session.botResponseCount} {t('Antworten')}</span>
</div>
<div className={styles.sessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button>
{session.status === 'active' && (
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
Stoppen
<div className={styles.tbDashSessionActions}>
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
{t('Live ansehen')}
</button>
{['active', 'joining', 'pending'].includes(session.status) && (
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
{t('Stoppen')}
</button>
)}
</div>
</div>
))}
</div>
</div>
</section>
)}
{/* Past Sessions */}
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>
{loading ? 'Lade Sitzungen...' : `Vergangene Sitzungen (${pastSessions.length})`}
</h3>
{pastSessions.length === 0 && !loading && (
<p className={styles.emptyState}>{t('Noch keine vergangenen Sitzungen')}</p>
)}
<div className={styles.sessionList}>
{pastSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}>
<div className={styles.sessionHeader}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.sessionMeta}>
<span>{session.transcriptSegmentCount} Segmente</span>
<span>{session.botResponseCount} Antworten</span>
{session.startedAt && <span>{new Date(session.startedAt).toLocaleDateString('de-CH')}</span>}
</div>
{session.summary && (
<div className={styles.sessionSummary}>{session.summary.substring(0, 200)}...</div>
)}
{session.errorMessage && (
<div className={styles.sessionError}>{session.errorMessage}</div>
)}
<div className={styles.sessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>Details</button>
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
Loeschen
</button>
</div>
</div>
))}
<section className={styles.tbDashSection}>
<div className={styles.tbDashSectionHead}>
<h2 className={styles.tbDashSectionTitle}>
{loading ? t('Laden…') : t('Letzte Sitzungen')}
</h2>
{pastSessions.length > PAST_LIMIT && (
<button
type="button"
className={styles.tbDashLinkBtn}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
>
{t('Alle in Modulen')}
</button>
)}
</div>
</div>
{pastVisible.length === 0 && !loading ? (
<p className={styles.emptyState}>{t('Noch keine beendeten Sitzungen')}</p>
) : (
<div className={styles.tbDashSessionList}>
{pastVisible.map((session) => (
<div key={session.id} className={styles.tbDashSessionRow}>
<div className={styles.tbDashSessionMain}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.tbDashSessionMeta}>
{session.startedAt && (
<span>{new Date(session.startedAt).toLocaleString('de-CH')}</span>
)}
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
</div>
<div className={styles.tbDashSessionActions}>
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
{t('Details')}
</button>
<button type="button" className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
{t('Löschen')}
</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
};

View file

@ -3,10 +3,11 @@
*
* CRUD list of MeetingModules with expandable session lists per module.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotSession } from '../../../api/teamsbotApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './Teamsbot.module.css';
@ -29,13 +30,23 @@ export const TeamsbotModulesView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const focusModuleId = searchParams.get('moduleId') || '';
const moduleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [modules, setModules] = useState<any[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
const [moduleSessions, setModuleSessions] = useState<Record<string, TeamsbotSession[]>>({});
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editingModule, setEditingModule] = useState<any | null>(null);
const [editingModule, setEditingModule] = useState<MeetingModule | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState('');
const [createSeriesType, setCreateSeriesType] = useState<string>('adhoc');
const [createDefaultLink, setCreateDefaultLink] = useState('');
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
const [createGoals, setCreateGoals] = useState('');
const [createSaving, setCreateSaving] = useState(false);
const _loadModules = useCallback(async () => {
if (!instanceId) return;
@ -62,6 +73,20 @@ export const TeamsbotModulesView: React.FC = () => {
}
}, [instanceId]);
useEffect(() => {
if (!focusModuleId || modules.length === 0) return;
if (!modules.some((m) => m.id === focusModuleId)) return;
setExpandedId(focusModuleId);
_loadModuleSessions(focusModuleId);
}, [focusModuleId, modules, _loadModuleSessions]);
useEffect(() => {
if (!focusModuleId || expandedId !== focusModuleId) return;
requestAnimationFrame(() => {
moduleRowRefs.current[focusModuleId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}, [focusModuleId, expandedId]);
const _toggleExpand = (moduleId: string) => {
if (expandedId === moduleId) {
setExpandedId(null);
@ -81,7 +106,7 @@ export const TeamsbotModulesView: React.FC = () => {
}
};
const _handleUpdate = async (moduleId: string, updates: any) => {
const _handleUpdate = async (moduleId: string, updates: Partial<MeetingModule>) => {
try {
await teamsbotApi.updateModule(instanceId, moduleId, updates);
setEditingModule(null);
@ -91,23 +116,72 @@ export const TeamsbotModulesView: React.FC = () => {
}
};
const _handleCreateModule = async () => {
if (!createTitle.trim()) return;
setCreateSaving(true);
try {
await teamsbotApi.createModule(instanceId, {
title: createTitle.trim(),
seriesType: createSeriesType,
defaultMeetingLink: createDefaultLink.trim() || undefined,
defaultBotName: createDefaultBotName.trim() || undefined,
goals: createGoals.trim() || undefined,
});
setCreateOpen(false);
setCreateTitle('');
setCreateSeriesType('adhoc');
setCreateDefaultLink('');
setCreateDefaultBotName('');
setCreateGoals('');
_loadModules();
} catch (err) {
console.error('Create module failed:', err);
} finally {
setCreateSaving(false);
}
};
const _formatSessionDate = (startedAt?: string | number) => {
if (startedAt == null) return '-';
if (typeof startedAt === 'number') {
return new Date(startedAt * 1000).toLocaleDateString('de-CH');
}
const ms = Date.parse(String(startedAt));
if (!Number.isNaN(ms)) return new Date(ms).toLocaleDateString('de-CH');
return '-';
};
return (
<div className={styles.modulesContainer}>
<div className={styles.modulesHeader}>
<h2>{t('Meeting-Module')}</h2>
<button
className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>
{t('Neues Modul')}
</button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<button
type="button"
className={styles.btnSecondary}
onClick={() => setCreateOpen(true)}
>
{t('Modul anlegen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>
{t('Meeting starten')}
</button>
</div>
</div>
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
<div className={styles.modulesList}>
{modules.map(mod => (
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
<div
key={mod.id}
ref={(el) => { moduleRowRefs.current[mod.id] = el; }}
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${focusModuleId === mod.id ? styles.moduleRowFocused : ''}`}
>
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
<span className={styles.moduleTitle}>{mod.title}</span>
@ -127,7 +201,7 @@ export const TeamsbotModulesView: React.FC = () => {
{(moduleSessions[mod.id] || []).length === 0 ? (
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
) : (
(moduleSessions[mod.id] || []).map((sess: any) => (
(moduleSessions[mod.id] || []).map((sess) => (
<div
key={sess.id}
className={styles.sessionRow}
@ -135,7 +209,7 @@ export const TeamsbotModulesView: React.FC = () => {
>
<span>{sess.botName || 'Bot'}</span>
<span className={styles.sessionStatus}>{sess.status}</span>
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
<span>{_formatSessionDate(sess.startedAt)}</span>
</div>
))
)}
@ -145,6 +219,66 @@ export const TeamsbotModulesView: React.FC = () => {
))}
</div>
{createOpen && (
<div className={styles.confirmOverlay}>
<div className={styles.editDialog}>
<h3>{t('Modul anlegen')}</h3>
<label className={styles.label}>{t('Titel')}</label>
<input
type="text"
className={styles.wizardInput}
value={createTitle}
onChange={e => setCreateTitle(e.target.value)}
placeholder={t('z.B. Weekly Standup')}
autoFocus
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Serientyp')}</label>
<select
className={styles.wizardSelect}
value={createSeriesType}
onChange={e => setCreateSeriesType(e.target.value)}
>
{Object.entries(SERIES_TYPE_LABELS).map(([code, lab]) => (
<option key={code} value={code}>{lab}</option>
))}
</select>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
<input
type="text"
className={styles.wizardInput}
value={createDefaultLink}
onChange={e => setCreateDefaultLink(e.target.value)}
placeholder="https://teams.microsoft.com/..."
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
<input
type="text"
className={styles.wizardInput}
value={createDefaultBotName}
onChange={e => setCreateDefaultBotName(e.target.value)}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
<textarea
className={styles.wizardTextarea}
rows={2}
value={createGoals}
onChange={e => setCreateGoals(e.target.value)}
/>
<div className={styles.confirmActions}>
<button type="button" className={styles.btnSecondary} onClick={() => setCreateOpen(false)}>{t('Abbrechen')}</button>
<button
type="button"
className={styles.btnPrimary}
onClick={_handleCreateModule}
disabled={!createTitle.trim() || createSaving}
>
{createSaving ? t('Speichern…') : t('Anlegen')}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (
<div className={styles.confirmOverlay}>
<div className={styles.confirmDialog}>
@ -167,6 +301,22 @@ export const TeamsbotModulesView: React.FC = () => {
className={styles.wizardInput}
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
<input
type="text"
defaultValue={editingModule.defaultMeetingLink || ''}
className={styles.wizardInput}
placeholder="https://teams.microsoft.com/l/meetup-join/..."
onBlur={e => setEditingModule({ ...editingModule, defaultMeetingLink: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
<input
type="text"
defaultValue={editingModule.defaultBotName || ''}
className={styles.wizardInput}
placeholder={t('z.B. AI Assistant')}
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
/>
<textarea
defaultValue={editingModule.goals || ''}
className={styles.wizardTextarea}
@ -179,6 +329,8 @@ export const TeamsbotModulesView: React.FC = () => {
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
title: editingModule.title,
goals: editingModule.goals,
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
defaultBotName: (editingModule.defaultBotName || '').trim(),
})}>{t('Speichern')}</button>
</div>
</div>

View file

@ -10,6 +10,7 @@ import type {
ScreenshotInfo,
DirectorPrompt,
DirectorPromptMode,
MfaChallengeEvent,
} from '../../../api/teamsbotApi';
import {
DIRECTOR_PROMPT_TEXT_LIMIT,
@ -80,6 +81,10 @@ export const TeamsbotSessionView: React.FC = () => {
// the gateway. Director prompts can only be processed once botConnected=true.)
const [botConnected, setBotConnected] = useState(false);
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
// UDB Sidebar state
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
@ -325,6 +330,31 @@ export const TeamsbotSessionView: React.FC = () => {
case 'ping':
break;
case 'mfaChallenge': {
const data = sseEvent.data as MfaChallengeEvent;
if (data.mfaType === 'timeout') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
} else {
setMfaChallenge(data);
setMfaCode('');
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
}
break;
}
case 'mfaResolved': {
setMfaChallenge(null);
setMfaWaitingPush(false);
teamsbotApi.getSession(instanceId, sessionId).then((result) => {
setSession(result.session);
if (result.transcripts) setTranscripts(result.transcripts);
if (result.botResponses) setBotResponses(result.botResponses);
}).catch(() => {});
break;
}
}
} catch (err) {
_dlog('SSE-ERR', String(err));
@ -358,8 +388,35 @@ export const TeamsbotSessionView: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, sessionId, reconnectTick]);
// Polling fallback: refresh session data every 5s when SSE is not connected.
// Uses isActive (boolean) instead of session object to prevent interval resets.
// Keep session switcher labels in sync when the selected session updates (SSE, poll, etc.).
useEffect(() => {
if (!session?.id) return;
setAllSessions((prev) => {
const idx = prev.findIndex((s) => s.id === session.id);
if (idx < 0) return prev;
const row = prev[idx];
if (
row.status === session.status
&& row.botName === session.botName
&& row.startedAt === session.startedAt
&& row.endedAt === session.endedAt
) {
return prev;
}
const next = [...prev];
next[idx] = {
...next[idx],
status: session.status,
botName: session.botName,
startedAt: session.startedAt,
endedAt: session.endedAt,
};
return next;
});
}, [session?.id, session?.status, session?.botName, session?.startedAt, session?.endedAt]);
// Polling: while joining/pending, poll even if SSE is connected (status may not arrive on stream).
// For active-only, poll only when SSE is down (previous behavior).
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
const isLiveRef = useRef(isLive);
@ -367,22 +424,24 @@ export const TeamsbotSessionView: React.FC = () => {
useEffect(() => {
if (!instanceId || !sessionId) return;
if (!isActive) return;
const intervalMs = session?.status === 'active' ? 5000 : 2500;
pollRef.current = setInterval(async () => {
if (isLiveRef.current) return;
const st = sessionStatusRef.current;
const pollWhileLive = st === 'pending' || st === 'joining';
if (!pollWhileLive && isLiveRef.current) return;
try {
const result = await teamsbotApi.getSession(instanceId, sessionId);
setSession(result.session);
if (result.transcripts) setTranscripts(result.transcripts);
if (result.botResponses) setBotResponses(result.botResponses);
// If session became active and SSE is dead, trigger reconnect
const newStatus = result.session?.status;
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
setReconnectTick(v => v + 1);
setReconnectTick((v) => v + 1);
}
} catch {}
}, 5000);
} catch { /* ignore */ }
}, intervalMs);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [isActive, instanceId, sessionId]);
}, [isActive, instanceId, sessionId, session?.status]);
// Auto-scroll transcript
useEffect(() => {
@ -398,6 +457,25 @@ export const TeamsbotSessionView: React.FC = () => {
}
};
const _mfaNeedsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
const _handleSubmitMfaCode = async () => {
if (!instanceId || !sessionId) return;
try {
await teamsbotApi.submitMfaCode(
instanceId,
sessionId,
_mfaNeedsCodeInput ? mfaCode : '',
_mfaNeedsCodeInput ? 'code' : 'confirmed',
);
if (!_mfaNeedsCodeInput) {
setMfaWaitingPush(true);
}
} catch (err: any) {
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
}
};
const _formatTime = (timestamp: string) => {
try {
const dt = new Date(timestamp);
@ -632,13 +710,16 @@ export const TeamsbotSessionView: React.FC = () => {
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
</p>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<button className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>{t('Zum Assistenten')}</button>
<button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
>{t('Zu den Modulen')}</button>
<button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/dashboard`)}
>{t('Zum Dashboard')}</button>
</div>
</div>
);
@ -650,6 +731,45 @@ export const TeamsbotSessionView: React.FC = () => {
return (
<div className={styles.sessionContainer}>
{mfaChallenge && (
<div className={styles.mfaOverlay}>
<div className={styles.mfaDialog}>
<div className={styles.mfaTitle}>{t('Multi-Faktor-Authentifizierung')}</div>
{mfaChallenge.displayNumber && (
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
)}
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
{_mfaNeedsCodeInput ? (
<>
<input
type="text"
className={styles.mfaCodeInput}
placeholder={t('Code eingeben')}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
autoFocus
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
/>
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
{t('Bestätigen')}
</button>
</>
) : mfaWaitingPush ? (
<>
<div className={styles.mfaSpinner} />
<p style={{ fontSize: '0.85rem', color: '#888' }}>
{t('Warte auf Bestätigung in der Authenticator-App …')}
</p>
</>
) : (
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode}>
{t('Ich habe bestätigt')}
</button>
)}
</div>
</div>
)}
{/* Session Switcher (if multiple sessions exist) */}
{allSessions.length > 1 && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>

View file

@ -246,7 +246,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
label: 'Teams Bot',
icon: 'headset_mic',
views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
{ code: 'modules', label: 'Module', path: 'modules' },
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },