fix:merge conflicts

This commit is contained in:
Ida Dittrich 2026-02-13 10:42:08 +01:00
commit 97a164f682
8 changed files with 1418 additions and 1 deletions

View file

@ -142,6 +142,18 @@ function App() {
<Route path="projects" element={<FeatureViewPage view="projects" />} />
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
{/* Chat Playground Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
{/* Automation Feature Views */}
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
</Route>

183
src/api/teamsbotApi.ts Normal file
View file

@ -0,0 +1,183 @@
import api from '../api';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface TeamsbotSession {
id: string;
instanceId: string;
mandateId: string;
meetingLink: string;
botName: string;
backgroundImageUrl?: string;
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
startedAt?: string;
endedAt?: string;
startedByUserId: string;
bridgeSessionId?: string;
meetingChatId?: string;
summary?: string;
errorMessage?: string;
transcriptSegmentCount: number;
botResponseCount: number;
creationDate?: string;
lastModified?: string;
}
export interface TeamsbotTranscript {
id: string;
sessionId: string;
speaker?: string;
text: string;
timestamp: string;
confidence: number;
language?: string;
isFinal: boolean;
}
export interface TeamsbotBotResponse {
id: string;
sessionId: string;
responseText: string;
responseType: 'audio' | 'chat' | 'both';
detectedIntent: 'addressed' | 'question' | 'proactive' | 'none';
reasoning?: string;
triggeredByTranscriptId?: string;
modelName?: string;
processingTime: number;
priceCHF: number;
timestamp?: string;
}
export interface TeamsbotConfig {
botName: string;
backgroundImageUrl?: string;
aiSystemPrompt: string;
responseMode: 'auto' | 'manual' | 'transcribeOnly';
language: string;
voiceId?: string;
bridgeUrl?: string;
triggerIntervalSeconds: number;
triggerCooldownSeconds: number;
contextWindowSegments: number;
}
export interface TeamsbotSessionStats {
transcriptSegments: number;
botResponses: number;
totalCostCHF: number;
totalProcessingTime: number;
speakers: string[];
}
export interface StartSessionRequest {
meetingLink: string;
botName?: string;
backgroundImageUrl?: string;
connectionId?: string;
}
export interface ConfigUpdateRequest {
botName?: string;
backgroundImageUrl?: string;
aiSystemPrompt?: string;
responseMode?: 'auto' | 'manual' | 'transcribeOnly';
language?: string;
voiceId?: string;
bridgeUrl?: string;
triggerIntervalSeconds?: number;
triggerCooldownSeconds?: number;
contextWindowSegments?: number;
}
// SSE Event Types
export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState';
data: any;
timestamp?: string;
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
/**
* Start a new Teams Bot session.
*/
export async function startSession(instanceId: string, request: StartSessionRequest): Promise<{ session: TeamsbotSession }> {
const response = await api.post(`/api/teamsbot/${instanceId}/sessions`, request);
return response.data;
}
/**
* List all sessions for a feature instance.
*/
export async function listSessions(instanceId: string, includeEnded = true): Promise<{ sessions: TeamsbotSession[] }> {
const response = await api.get(`/api/teamsbot/${instanceId}/sessions`, {
params: { includeEnded },
});
return response.data;
}
/**
* Get session details with transcripts and bot responses.
*/
export async function getSession(
instanceId: string,
sessionId: string,
includeTranscripts = true,
includeResponses = true,
): Promise<{
session: TeamsbotSession;
transcripts?: TeamsbotTranscript[];
botResponses?: TeamsbotBotResponse[];
stats?: TeamsbotSessionStats;
}> {
const response = await api.get(`/api/teamsbot/${instanceId}/sessions/${sessionId}`, {
params: { includeTranscripts, includeResponses },
});
return response.data;
}
/**
* Stop an active session.
*/
export async function stopSession(instanceId: string, sessionId: string): Promise<{ status: string; sessionId: string }> {
const response = await api.post(`/api/teamsbot/${instanceId}/sessions/${sessionId}/stop`);
return response.data;
}
/**
* Delete a session and all related data.
*/
export async function deleteSession(instanceId: string, sessionId: string): Promise<{ deleted: boolean }> {
const response = await api.delete(`/api/teamsbot/${instanceId}/sessions/${sessionId}`);
return response.data;
}
/**
* Get teamsbot configuration.
*/
export async function getConfig(instanceId: string): Promise<{ config: TeamsbotConfig }> {
const response = await api.get(`/api/teamsbot/${instanceId}/config`);
return response.data;
}
/**
* Update teamsbot configuration.
*/
export async function updateConfig(instanceId: string, updates: ConfigUpdateRequest): Promise<{ config: TeamsbotConfig }> {
const response = await api.put(`/api/teamsbot/${instanceId}/config`, updates);
return response.data;
}
/**
* Create an SSE EventSource for live session streaming.
* Returns the EventSource instance for the caller to manage.
*/
export function createSessionStream(instanceId: string, sessionId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/sessions/${sessionId}/stream`;
return new EventSource(url, { withCredentials: true });
}

View file

@ -20,7 +20,8 @@ import {
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo,
} from 'react-icons/fa';
// =============================================================================
@ -79,6 +80,11 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.feature.realestate.projects': <FaProjectDiagram />,
'page.feature.realestate.parcels': <FaMapMarkedAlt />,
// Feature pages - Teams Bot
'page.feature.teamsbot.dashboard': <FaChartLine />,
'page.feature.teamsbot.sessions': <FaVideo />,
'page.feature.teamsbot.settings': <FaCog />,
// Feature icons (for feature grouping in navigation)
'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />,
@ -86,6 +92,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.chatplayground': <FaPlay />,
'feature.automation': <FaCogs />,
'feature.chatbot': <FaComments />,
'feature.teamsbot': <FaHeadset />,
};
// =============================================================================

View file

@ -25,6 +25,17 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
// RealEstate Views
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
// Chat Playground Views (reusing existing workflow pages)
import { PlaygroundPage, WorkflowsPage } from './workflows';
// Automation Views (reusing existing workflow pages)
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
// Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
import styles from './FeatureView.module.css';
// =============================================================================
@ -103,6 +114,20 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
parcels: RealEstateParcelsView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
chatplayground: {
playground: PlaygroundPage,
workflows: WorkflowsPage,
},
automation: {
definitions: AutomationsPage,
templates: AutomationTemplatesPage,
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
},
teamsbot: {
dashboard: TeamsbotDashboardView,
sessions: TeamsbotSessionView,
settings: TeamsbotSettingsView,
},
};
// =============================================================================

View file

@ -0,0 +1,497 @@
/* ============================================================================
Teamsbot Feature Styles
============================================================================ */
/* Dashboard */
.dashboardContainer {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 960px;
}
.startSessionCard {
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1.5rem;
}
.cardTitle {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
}
.cardDescription {
margin: 0 0 1rem 0;
color: var(--text-secondary, #666);
font-size: 0.9rem;
}
.formGroup {
margin-bottom: 1rem;
}
.formRow {
display: flex;
gap: 1rem;
}
.formRow .formGroup {
flex: 1;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.input,
.select,
.textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
background: var(--input-bg, #fff);
color: var(--text-primary, #333);
box-sizing: border-box;
}
.input:focus,
.select:focus,
.textarea:focus {
outline: none;
border-color: var(--primary-color, #4A90D9);
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.15);
}
.textarea {
resize: vertical;
font-family: inherit;
min-height: 100px;
}
.startButton,
.saveButton {
padding: 0.6rem 1.5rem;
background: var(--primary-color, #4A90D9);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.startButton:hover,
.saveButton:hover {
background: var(--primary-hover, #3A7BC8);
}
.startButton:disabled,
.saveButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stopButton {
padding: 0.4rem 1rem;
background: var(--danger-color, #D94A4A);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.stopButton:hover {
background: var(--danger-hover, #C83A3A);
}
.viewButton {
padding: 0.4rem 1rem;
background: var(--surface-color, #f5f5f5);
color: var(--primary-color, #4A90D9);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.85rem;
text-decoration: none;
cursor: pointer;
}
.deleteButton {
padding: 0.4rem 1rem;
background: transparent;
color: var(--danger-color, #D94A4A);
border: 1px solid var(--danger-color, #D94A4A);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
/* Error/Success Banners */
.errorBanner {
background: rgba(217, 74, 74, 0.1);
color: var(--danger-color, #D94A4A);
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
border-left: 3px solid var(--danger-color, #D94A4A);
}
.successBanner {
background: rgba(74, 217, 154, 0.1);
color: var(--success-color, #2D8E5C);
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
border-left: 3px solid var(--success-color, #2D8E5C);
}
/* Section */
.sectionContainer {
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1.5rem;
}
.sectionTitle {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
/* Session List */
.sessionList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sessionCard {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 1rem;
background: var(--surface-alt, #fafafa);
}
.sessionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.sessionBotName {
font-weight: 600;
font-size: 0.95rem;
}
.sessionMeta {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
}
.sessionActions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.sessionSummary {
font-size: 0.85rem;
color: var(--text-secondary, #666);
background: var(--surface-color, #fff);
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
}
.sessionError {
font-size: 0.85rem;
color: var(--danger-color, #D94A4A);
margin-top: 0.5rem;
}
/* Status Badges */
.statusBadge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.statusActive {
background: rgba(74, 217, 154, 0.15);
color: #2D8E5C;
}
.statusJoining,
.statusPending {
background: rgba(74, 144, 217, 0.15);
color: #4A90D9;
}
.statusEnded {
background: rgba(128, 128, 128, 0.15);
color: #666;
}
.statusError {
background: rgba(217, 74, 74, 0.15);
color: #D94A4A;
}
.statusLeaving {
background: rgba(217, 168, 74, 0.15);
color: #B8860B;
}
.liveBadge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
color: #fff;
background: #D94A4A;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.emptyState {
color: var(--text-tertiary, #999);
font-size: 0.9rem;
padding: 2rem;
text-align: center;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
/* ============================================================================
Session View
============================================================================ */
.sessionContainer {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
}
.sessionViewHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
}
.sessionInfo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.sessionTitle {
margin: 0;
font-size: 1.1rem;
}
.sessionControls {
display: flex;
gap: 0.5rem;
}
.sessionContent {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
flex: 1;
min-height: 0;
}
/* Transcript Panel */
.transcriptPanel,
.responsesPanel {
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panelTitle {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 600;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-alt, #fafafa);
}
.transcriptList,
.responseList {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.transcriptItem {
display: flex;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.4;
}
.transcriptItem:hover {
background: var(--surface-alt, #f5f5f5);
}
.transcriptTime {
color: var(--text-tertiary, #999);
font-size: 0.75rem;
flex-shrink: 0;
min-width: 55px;
}
.transcriptSpeaker {
font-weight: 600;
flex-shrink: 0;
}
.transcriptText {
color: var(--text-primary, #333);
}
/* Response Items */
.responseItem {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--surface-alt, #fafafa);
}
.responseHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.responseIntent {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--primary-color, #4A90D9);
}
.responseTime {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.responseText {
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-primary, #333);
}
.responseReasoning {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary, #666);
}
.responseMeta {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
/* Summary Card */
.summaryCard {
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
overflow: hidden;
}
.summaryText {
padding: 1rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-primary, #333);
white-space: pre-wrap;
}
/* ============================================================================
Settings View
============================================================================ */
.settingsContainer {
padding: 1rem;
max-width: 720px;
}
.settingsCard {
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1.5rem;
}
.settingsSection {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.settingsSection:last-of-type {
border-bottom: none;
}
.settingsActions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
}

View file

@ -0,0 +1,228 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, StartSessionRequest } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
/**
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
* Allows starting new sessions and viewing active/past sessions.
*/
export const TeamsbotDashboardView: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New session form
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('');
const [isStarting, setIsStarting] = useState(false);
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 || 'Fehler beim Laden der Sitzungen');
} finally {
setLoading(false);
}
}, [instanceId]);
useEffect(() => {
_loadSessions();
}, [_loadSessions]);
const _handleStartSession = async () => {
if (!meetingLink.trim()) return;
setIsStarting(true);
setError(null);
try {
const request: StartSessionRequest = {
meetingLink: meetingLink.trim(),
botName: botName.trim() || undefined,
};
await teamsbotApi.startSession(instanceId, request);
setMeetingLink('');
setBotName('');
await _loadSessions();
} catch (err: any) {
setError(err.message || 'Fehler beim Starten der Sitzung');
} finally {
setIsStarting(false);
}
};
const _handleStopSession = async (sessionId: string) => {
try {
await teamsbotApi.stopSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || 'Fehler beim Stoppen der Sitzung');
}
};
const _handleDeleteSession = async (sessionId: string) => {
try {
await teamsbotApi.deleteSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || 'Fehler beim Loeschen der Sitzung');
}
};
const _getStatusBadgeClass = (status: string) => {
switch (status) {
case 'active': return styles.statusActive;
case 'joining': return styles.statusJoining;
case 'pending': return styles.statusPending;
case 'ended': return styles.statusEnded;
case 'error': return styles.statusError;
case 'leaving': return styles.statusLeaving;
default: return '';
}
};
const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: 'Wartend',
joining: 'Beitritt...',
active: 'Aktiv',
leaving: 'Verlassen...',
ended: 'Beendet',
error: '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));
return (
<div className={styles.dashboardContainer}>
{/* Start New Session Card */}
<div className={styles.startSessionCard}>
<h3 className={styles.cardTitle}>Neue Bot-Sitzung 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}>Teams Meeting-Link *</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}>Bot-Name (optional)</label>
<input
type="text"
className={styles.input}
placeholder="AI Assistant"
value={botName}
onChange={(e) => setBotName(e.target.value)}
disabled={isStarting}
/>
</div>
<button
className={styles.startButton}
onClick={_handleStartSession}
disabled={isStarting || !meetingLink.trim()}
>
{isStarting ? 'Wird gestartet...' : 'Bot ins Meeting senden'}
</button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Active Sessions */}
{activeSessions.length > 0 && (
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>Aktive Sitzungen</h3>
<div className={styles.sessionList}>
{activeSessions.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>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
</div>
<div className={styles.sessionActions}>
<a href={`sessions?sessionId=${session.id}`} className={styles.viewButton}>Live ansehen</a>
{session.status === 'active' && (
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
Stoppen
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* 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}>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}>
<a href={`sessions?sessionId=${session.id}`} className={styles.viewButton}>Details</a>
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
Loeschen
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default TeamsbotDashboardView;

View file

@ -0,0 +1,225 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
/**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
*/
export const TeamsbotSessionView: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('sessionId') || '';
const [session, setSession] = useState<TeamsbotSession | null>(null);
const [transcripts, setTranscripts] = useState<TeamsbotTranscript[]>([]);
const [botResponses, setBotResponses] = useState<TeamsbotBotResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isLive, setIsLive] = useState(false);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// Load session data
const _loadSession = useCallback(async () => {
if (!instanceId || !sessionId) return;
try {
setLoading(true);
const result = await teamsbotApi.getSession(instanceId, sessionId);
setSession(result.session);
setTranscripts(result.transcripts || []);
setBotResponses(result.botResponses || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Sitzung');
} finally {
setLoading(false);
}
}, [instanceId, sessionId]);
useEffect(() => {
_loadSession();
}, [_loadSession]);
// SSE Live Stream
useEffect(() => {
if (!instanceId || !sessionId || !session) return;
if (!['active', 'joining', 'pending'].includes(session.status)) return;
const eventSource = teamsbotApi.createSessionStream(instanceId, sessionId);
eventSourceRef.current = eventSource;
setIsLive(true);
eventSource.onmessage = (event) => {
try {
const sseEvent: TeamsbotSSEEvent = JSON.parse(event.data);
switch (sseEvent.type) {
case 'transcript':
setTranscripts(prev => [...prev, sseEvent.data as TeamsbotTranscript]);
break;
case 'botResponse':
setBotResponses(prev => [...prev, sseEvent.data as TeamsbotBotResponse]);
break;
case 'statusChange':
setSession(prev => prev ? { ...prev, status: sseEvent.data.status } : null);
if (['ended', 'error'].includes(sseEvent.data.status)) {
setIsLive(false);
eventSource.close();
}
break;
case 'analysis':
// Debug info - could show in UI
break;
case 'suggestedResponse':
// Manual mode: show suggested response
break;
case 'ping':
break;
}
} catch (err) {
console.error('SSE parse error:', err);
}
};
eventSource.onerror = () => {
setIsLive(false);
};
return () => {
eventSource.close();
eventSourceRef.current = null;
setIsLive(false);
};
}, [instanceId, sessionId, session?.status]);
// Auto-scroll transcript
useEffect(() => {
transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [transcripts]);
const _handleStop = async () => {
if (!instanceId || !sessionId) return;
try {
await teamsbotApi.stopSession(instanceId, sessionId);
} catch (err: any) {
setError(err.message);
}
};
const _formatTime = (timestamp: string) => {
try {
return new Date(timestamp).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '';
}
};
const _getSpeakerColor = (speaker: string) => {
const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
let hash = 0;
for (let i = 0; i < speaker.length; i++) {
hash = speaker.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
if (loading) return <div className={styles.loading}>Lade Sitzung...</div>;
if (!session) return <div className={styles.errorBanner}>Sitzung nicht gefunden</div>;
return (
<div className={styles.sessionContainer}>
{/* Session Header */}
<div className={styles.sessionViewHeader}>
<div className={styles.sessionInfo}>
<h3 className={styles.sessionTitle}>{session.botName}</h3>
<span className={`${styles.statusBadge} ${styles[`status${session.status.charAt(0).toUpperCase() + session.status.slice(1)}`] || ''}`}>
{session.status}
</span>
{isLive && <span className={styles.liveBadge}>LIVE</span>}
</div>
<div className={styles.sessionControls}>
{['active', 'joining'].includes(session.status) && (
<button className={styles.stopButton} onClick={_handleStop}>Sitzung beenden</button>
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Main Content: Transcript + Responses */}
<div className={styles.sessionContent}>
{/* Left: Transcript */}
<div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}>Transkript ({transcripts.length} Segmente)</h4>
<div className={styles.transcriptList}>
{transcripts.map((t) => (
<div key={t.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(t.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(t.speaker || 'Unknown') }}
>
{t.speaker || 'Unknown'}:
</span>
<span className={styles.transcriptText}>{t.text}</span>
</div>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>Noch kein Transkript vorhanden.</div>
)}
</div>
</div>
{/* Right: Bot Responses */}
<div className={styles.responsesPanel}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
<div className={styles.responseList}>
{botResponses.map((r) => (
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>{r.responseText}</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>Reasoning: {r.reasoning}</em>
</div>
)}
<div className={styles.responseMeta}>
<span>{r.modelName}</span>
<span>{r.processingTime.toFixed(1)}s</span>
<span>{r.priceCHF.toFixed(4)} CHF</span>
</div>
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>Noch keine Bot-Antworten.</div>
)}
</div>
</div>
</div>
{/* Summary (for ended sessions) */}
{session.summary && (
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>Meeting-Zusammenfassung</h4>
<div className={styles.summaryText}>{session.summary}</div>
</div>
)}
</div>
);
};
export default TeamsbotSessionView;

View file

@ -0,0 +1,240 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
/**
* TeamsbotSettingsView - Bot configuration for a feature instance.
*/
export const TeamsbotSettingsView: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [config, setConfig] = useState<TeamsbotConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
const _loadConfig = useCallback(async () => {
if (!instanceId) return;
try {
setLoading(true);
const result = await teamsbotApi.getConfig(instanceId);
setConfig(result.config);
setFormData(result.config);
setError(null);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Konfiguration');
} finally {
setLoading(false);
}
}, [instanceId]);
useEffect(() => {
_loadConfig();
}, [_loadConfig]);
const _handleSave = async () => {
if (!instanceId) return;
setSaving(true);
setError(null);
setSuccessMsg(null);
try {
const result = await teamsbotApi.updateConfig(instanceId, formData);
setConfig(result.config);
setFormData(result.config);
setSuccessMsg('Konfiguration gespeichert');
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const _updateField = (field: keyof ConfigUpdateRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>;
return (
<div className={styles.settingsContainer}>
<div className={styles.settingsCard}>
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
{error && <div className={styles.errorBanner}>{error}</div>}
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
{/* Bot Identity */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Bot-Identitaet</h4>
<div className={styles.formGroup}>
<label className={styles.label}>Bot-Name</label>
<input
type="text"
className={styles.input}
value={formData.botName || ''}
onChange={(e) => _updateField('botName', e.target.value)}
placeholder="AI Assistant"
/>
<span className={styles.hint}>Wird als Teilnehmer-Name im Meeting angezeigt</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Hintergrundbild-URL</label>
<input
type="url"
className={styles.input}
value={formData.backgroundImageUrl || ''}
onChange={(e) => _updateField('backgroundImageUrl', e.target.value)}
placeholder="https://example.com/background.jpg"
/>
<span className={styles.hint}>Hintergrundbild fuer den Video-Feed des Bots</span>
</div>
</div>
{/* AI Behavior */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>AI-Verhalten</h4>
<div className={styles.formGroup}>
<label className={styles.label}>System-Prompt</label>
<textarea
className={styles.textarea}
value={formData.aiSystemPrompt || ''}
onChange={(e) => _updateField('aiSystemPrompt', e.target.value)}
rows={6}
placeholder="Beschreibe, wie sich der Bot im Meeting verhalten soll..."
/>
<span className={styles.hint}>Instruktionen fuer den AI-Bot: Wann und wie soll er antworten?</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Antwort-Modus</label>
<select
className={styles.select}
value={formData.responseMode || 'auto'}
onChange={(e) => _updateField('responseMode', e.target.value)}
>
<option value="auto">Automatisch - AI entscheidet selbst</option>
<option value="manual">Manuell - Antworten muessen bestaetigt werden</option>
<option value="transcribeOnly">Nur Transkription - Keine AI-Antworten</option>
</select>
</div>
</div>
{/* Voice Settings */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Sprach-Einstellungen</h4>
<div className={styles.formGroup}>
<label className={styles.label}>Sprache</label>
<select
className={styles.select}
value={formData.language || 'de-DE'}
onChange={(e) => _updateField('language', e.target.value)}
>
<option value="de-DE">Deutsch (Deutschland)</option>
<option value="de-CH">Deutsch (Schweiz)</option>
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
<option value="fr-FR">Francais</option>
<option value="it-IT">Italiano</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Stimme (Voice ID)</label>
<input
type="text"
className={styles.input}
value={formData.voiceId || ''}
onChange={(e) => _updateField('voiceId', e.target.value)}
placeholder="de-DE-Standard-A"
/>
<span className={styles.hint}>Google TTS Voice ID fuer die Sprachausgabe</span>
</div>
</div>
{/* Advanced Settings */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Erweiterte Einstellungen</h4>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label className={styles.label}>Analyse-Intervall (Sek.)</label>
<input
type="number"
className={styles.input}
value={formData.triggerIntervalSeconds || 10}
onChange={(e) => _updateField('triggerIntervalSeconds', parseInt(e.target.value))}
min={3}
max={60}
/>
<span className={styles.hint}>Periodisches Analyse-Intervall</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Cooldown (Sek.)</label>
<input
type="number"
className={styles.input}
value={formData.triggerCooldownSeconds || 3}
onChange={(e) => _updateField('triggerCooldownSeconds', parseInt(e.target.value))}
min={1}
max={30}
/>
<span className={styles.hint}>Min. Abstand zwischen AI-Calls</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Kontext-Fenster (Segmente)</label>
<input
type="number"
className={styles.input}
value={formData.contextWindowSegments || 20}
onChange={(e) => _updateField('contextWindowSegments', parseInt(e.target.value))}
min={5}
max={100}
/>
<span className={styles.hint}>Anzahl Transkript-Segmente fuer AI-Kontext</span>
</div>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Media Bridge URL</label>
<input
type="url"
className={styles.input}
value={formData.bridgeUrl || ''}
onChange={(e) => _updateField('bridgeUrl', e.target.value)}
placeholder="https://bridge.example.com"
/>
<span className={styles.hint}>URL des .NET Media Bridge Service</span>
</div>
</div>
{/* Save Button */}
<div className={styles.settingsActions}>
<button
className={styles.saveButton}
onClick={_handleSave}
disabled={saving}
>
{saving ? 'Speichern...' : 'Konfiguration speichern'}
</button>
</div>
</div>
</div>
);
};
export default TeamsbotSettingsView;